diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e99b547ec8..1f488c89af 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -979,6 +979,9 @@ const ( // GatewayConfig API网关相关配置 type GatewayConfig struct { + // MinOutputTokens is the minimum max_output_tokens used when + // converting Chat Completions or Anthropic requests to Responses. + MinOutputTokens int `mapstructure:"min_output_tokens"` // 等待上游响应头的超时时间(秒),0表示无超时 // 注意:这不影响流式数据传输,只控制等待响应头的时间 ResponseHeaderTimeout int `mapstructure:"response_header_timeout"` @@ -2035,6 +2038,7 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) { func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) + viper.SetDefault("gateway.min_output_tokens", 128) viper.SetDefault("simple_mode.auto_create_default_groups", true) viper.SetDefault("simple_mode_key_rate_limit_enabled", false) @@ -2707,6 +2711,9 @@ func setEnvReachableDefaults() { } func (c *Config) Validate() error { + if c.Gateway.MinOutputTokens < 16 { + return fmt.Errorf("gateway.min_output_tokens must be at least 16") + } forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders) if err != nil { return fmt.Errorf("security.forwarded_client_ip_headers: %w", err) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 96cac60f43..434b889955 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -31,6 +31,29 @@ func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) { require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes) } +func TestLoadDefaultMinOutputTokens(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, 128, cfg.Gateway.MinOutputTokens) +} + +func TestLoadMinOutputTokensFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_MIN_OUTPUT_TOKENS", "16") + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, 16, cfg.Gateway.MinOutputTokens) +} + +func TestValidateMinOutputTokensMinimum(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + cfg.Gateway.MinOutputTokens = 15 + require.EqualError(t, cfg.Validate(), "gateway.min_output_tokens must be at least 16") +} + func TestLoadTimezonePrecedence(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 0de8078669..8337488931 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -908,14 +908,16 @@ func buildCodexAgentIdentityKeys(accountID string) []string { return []string{"account:" + accountID} } -// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度, -// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。 +// buildCodexStoredIdentityKeys 生成存量账号索引键,按 workspace/account +// 和 user 组合隔离身份,避免同一用户在不同 workspace 的账号误合并。 func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string { keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) accessToken = strings.TrimSpace(accessToken) - if userID != "" { + if accountID != "" && userID != "" { + keys = append(keys, "account_user:"+codexCompositeIdentityKey(accountID, userID)) + } else if userID != "" { keys = append(keys, "user:"+userID) } if accountID == "" && userID == "" { @@ -932,6 +934,10 @@ func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) return keys } +func codexCompositeIdentityKey(accountID, userID string) string { + return fmt.Sprintf("%d:%s%d:%s", len(accountID), accountID, len(userID), userID) +} + func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { index := &codexAccountIndex{ accountsByKey: map[string][]service.Account{}, diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go index dc2903ccbe..e26a112dad 100644 --- a/backend/internal/handler/admin/account_codex_import_test.go +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -303,8 +303,8 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh") - if len(keys) == 0 || keys[0] != "user:user-1" { - t.Fatalf("user key should have highest priority when refresh token exists: %v", keys) + if len(keys) == 0 || keys[0] != "account_user:6:acct-16:user-1" { + t.Fatalf("workspace-scoped user key should have highest priority when refresh token exists: %v", keys) } if keys[len(keys)-1] != "account:acct-1" { t.Fatalf("shared account key should be the last fallback: %v", keys) @@ -465,12 +465,84 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { if got == nil || got.ID != member.ID { t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID) } - if matchedKey != "user:user-2" { - t.Fatalf("matched key = %q, want user:user-2", matchedKey) + if matchedKey != "account_user:6:team-16:user-2" { + t.Fatalf("matched key = %q, want account_user:6:team-16:user-2", matchedKey) } } } +func TestImportCodexSessionsSameUserDifferentWorkspacesCreatesSeparateAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "shared-user", "refresh-1")}, + {Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-2", "shared-user", "refresh-2")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } + if got := svc.createdAccounts[0].Credentials["chatgpt_account_id"]; got != "workspace-1" { + t.Fatalf("first workspace = %v, want workspace-1", got) + } + if got := svc.createdAccounts[1].Credentials["chatgpt_account_id"]; got != "workspace-2" { + t.Fatalf("second workspace = %v, want workspace-2", got) + } +} + +func TestCodexImportIdentityKeySeparatesDelimiterValues(t *testing.T) { + left := buildCodexImportIdentityKeys("ab:c", "shared-user", "", "", "refresh") + right := buildCodexImportIdentityKeys("ab", "c:shared-user", "", "", "refresh") + if left[0] == right[0] { + t.Fatalf("composite identity keys collided: %q", left[0]) + } +} + +func TestImportCodexSessionsSameWorkspaceAccountUpdatesAndDifferentUserIsolated(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + initialEntries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-1")}, + {Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-2", "refresh-2")}, + } + + initialResult, err := handler.importCodexSessions(context.Background(), req, initialEntries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if initialResult.Created != 2 || initialResult.Updated != 0 || initialResult.Skipped != 0 || initialResult.Failed != 0 { + t.Fatalf("initial result = %+v, want two created accounts", initialResult) + } + + updateResult, err := handler.importCodexSessions(context.Background(), req, []codexImportEntry{ + {Index: 3, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-3")}, + }) + if err != nil { + t.Fatalf("importCodexSessions update error = %v", err) + } + if updateResult.Created != 0 || updateResult.Updated != 1 || updateResult.Skipped != 0 || updateResult.Failed != 0 { + t.Fatalf("update result = %+v, want one updated account", updateResult) + } + if len(svc.createdAccounts) != 2 || len(svc.updatedAccounts) != 1 { + t.Fatalf("created = %d, updated = %d, want 2 and 1", len(svc.createdAccounts), len(svc.updatedAccounts)) + } + if svc.updatedAccounts[0].id != 100 { + t.Fatalf("updated account ID = %d, want user-1 account ID 100", svc.updatedAccounts[0].id) + } + if got := svc.updatedAccounts[0].input.Credentials["chatgpt_user_id"]; got != "user-1" { + t.Fatalf("updated user = %v, want user-1", got) + } +} + func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { legacy := service.Account{ ID: 40, diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index dc70f120e7..be987fbbcd 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -103,7 +103,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) headers[strings.ToLower(k)] = c.GetHeader(k) } - resolvedProviderKey, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers) + verifiedProvider, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers) if err != nil { truncatedBody := rawBody if len(truncatedBody) > webhookLogTruncateLen { @@ -115,12 +115,20 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) return } + resolvedProviderKey := verifiedProvider.ProviderKey() + // nil notification means irrelevant event (e.g. Stripe non-payment event); return success. if notification == nil { writeSuccessResponse(c, resolvedProviderKey) return } + if err := h.paymentService.ConfirmNotificationUpstream(c.Request.Context(), verifiedProvider, notification); err != nil { + slog.Error("[Payment Webhook] upstream confirmation failed", "provider", resolvedProviderKey, "outTradeNo", notification.OrderID, "tradeNo", notification.TradeNo, "error", err) + c.String(http.StatusBadGateway, "confirm failed") + return + } + if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil { // Unknown order: ack with 2xx so the provider stops retrying. This // guards against foreign environments whose webhook endpoints are @@ -170,7 +178,10 @@ func extractOutTradeNo(rawBody, providerKey string) string { return "" } -func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) { +// verifyNotificationWithProviders returns the provider instance that verified +// the notification so later steps (upstream confirmation) use the same +// merchant configuration. +func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *payment.PaymentNotification, error) { var lastErr error for _, provider := range providers { if provider == nil { @@ -181,12 +192,12 @@ func verifyNotificationWithProviders(ctx context.Context, providers []payment.Pr lastErr = err continue } - return provider.ProviderKey(), notification, nil + return provider, notification, nil } if lastErr != nil { - return "", nil, lastErr + return nil, nil, lastErr } - return "", nil, fmt.Errorf("no webhook provider could verify notification") + return nil, nil, fmt.Errorf("no webhook provider could verify notification") } // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook. diff --git a/backend/internal/handler/payment_webhook_handler_test.go b/backend/internal/handler/payment_webhook_handler_test.go index 5b613383c9..26f5a46548 100644 --- a/backend/internal/handler/payment_webhook_handler_test.go +++ b/backend/internal/handler/payment_webhook_handler_test.go @@ -204,9 +204,9 @@ func TestVerifyNotificationWithProvidersReturnsMatchedProvider(t *testing.T) { }, } - providerKey, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"}) + provider, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"}) require.NoError(t, err) - require.Equal(t, payment.TypeWxpay, providerKey) + require.Equal(t, payment.TypeWxpay, provider.ProviderKey()) require.NotNil(t, notification) require.Equal(t, "sub2_42", notification.OrderID) } diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index 42a9387171..dbd0e9cccb 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/smartwalle/alipay/v3" ) @@ -24,7 +25,10 @@ const ( const ( alipayFundChangeYes = "Y" alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST" - alipayRefundSuffix = "-refund" + alipayRefundStatusSucc = "REFUND_SUCCESS" + alipayRefundRequestSep = "-refund-" + // 支付宝提示退款发起后过早查询可能查不到、之后又处理成功;超过该时长仍无记录才判定失败。 + alipayRefundQueryNotFoundGrace = 10 * time.Minute ) var ( @@ -37,6 +41,12 @@ var ( alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) { return client.TradePagePay(param) } + alipayTradeRefund = func(ctx context.Context, client *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) { + return client.TradeRefund(ctx, param) + } + alipayTradeFastPayRefundQuery = func(ctx context.Context, client *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + return client.TradeFastPayRefundQuery(ctx, param) + } ) // Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK. @@ -338,32 +348,76 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen return nil, err } - result, err := client.TradeRefund(ctx, alipay.TradeRefund{ + outRequestNo := fmt.Sprintf("%s%s%d", req.OrderID, alipayRefundRequestSep, time.Now().UnixNano()) + result, err := alipayTradeRefund(ctx, client, alipay.TradeRefund{ OutTradeNo: req.OrderID, RefundAmount: req.Amount, RefundReason: req.Reason, - OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()), + OutRequestNo: outRequestNo, }) if err != nil { return nil, fmt.Errorf("alipay TradeRefund: %w", err) } + if result == nil { + return nil, fmt.Errorf("alipay TradeRefund: empty response") + } refundStatus := payment.ProviderStatusPending if result.FundChange == alipayFundChangeYes { refundStatus = payment.ProviderStatusSuccess } - refundID := result.TradeNo - if refundID == "" { - refundID = req.OrderID + alipayRefundSuffix - } - + // fund_change != Y 时需凭退款请求号查询终态,因此 RefundID 记录 out_request_no。 return &payment.RefundResponse{ - RefundID: refundID, + RefundID: outRequestNo, Status: refundStatus, }, nil } +// QueryRefund 通过 alipay.trade.fastpay.refund.query 查询退款终态。refund_status 为 REFUND_SUCCESS +// 表示成功;退款发起超过宽限期仍查不到记录表示支付宝未受理或退款失败;其余情况视为仍在处理中。 +func (a *Alipay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) { + orderID := strings.TrimSpace(req.OrderID) + outRequestNo := strings.TrimSpace(req.RefundID) + // 旧版本把支付宝交易号记为 RefundID,无法据此查询退款请求,只能人工核实。 + if orderID == "" || !strings.HasPrefix(outRequestNo, orderID+alipayRefundRequestSep) { + return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this refund was issued without a queryable refund request number; please verify manually") + } + client, err := a.getClient() + if err != nil { + return nil, err + } + result, err := alipayTradeFastPayRefundQuery(ctx, client, alipay.TradeFastPayRefundQuery{ + OutTradeNo: orderID, + OutRequestNo: outRequestNo, + }) + if err != nil { + return nil, fmt.Errorf("alipay query refund: %w", err) + } + if result == nil { + return nil, fmt.Errorf("alipay query refund: empty response") + } + // SDK 对带签名的业务失败响应(如 ACQ.SYSTEM_ERROR)不返回 error,字段为空; + // 不检查错误码会被误判为"无退款记录"而失败收尾。 + if result.IsFailure() { + return nil, fmt.Errorf("alipay query refund: %w", result.Error) + } + + status := payment.ProviderStatusPending + switch { + case strings.TrimSpace(result.RefundStatus) == alipayRefundStatusSucc: + status = payment.ProviderStatusSuccess + case strings.TrimSpace(result.OutRequestNo) == "" && strings.TrimSpace(result.RefundAmount) == "" && + alipayRefundAbsenceIsFinal(req.RequestedAt, time.Now()): + status = payment.ProviderStatusFailed + } + return &payment.RefundResponse{RefundID: outRequestNo, Status: status}, nil +} + +func alipayRefundAbsenceIsFinal(requestedAt, now time.Time) bool { + return !requestedAt.IsZero() && now.Sub(requestedAt) >= alipayRefundQueryNotFoundGrace +} + // CancelPayment closes a pending trade on Alipay. func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error { client, err := a.getClient() @@ -406,5 +460,6 @@ func parseAlipayAmount(values ...string) (float64, error) { var ( _ payment.Provider = (*Alipay)(nil) _ payment.CancelableProvider = (*Alipay)(nil) + _ payment.RefundQueryProvider = (*Alipay)(nil) _ payment.MerchantIdentityProvider = (*Alipay)(nil) ) diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go index bb725f8c2f..a2264b2eef 100644 --- a/backend/internal/payment/provider/alipay_test.go +++ b/backend/internal/payment/provider/alipay_test.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/smartwalle/alipay/v3" @@ -444,3 +445,129 @@ func TestParseAlipayAmount(t *testing.T) { t.Fatal("expected error when no valid amount field exists") } } + +func TestAlipayRefundRecordsOutRequestNoAsRefundID(t *testing.T) { + origRefund := alipayTradeRefund + t.Cleanup(func() { alipayTradeRefund = origRefund }) + + for _, tc := range []struct { + fundChange string + wantStatus string + }{ + {fundChange: alipayFundChangeYes, wantStatus: payment.ProviderStatusSuccess}, + {fundChange: "N", wantStatus: payment.ProviderStatusPending}, + } { + var gotOutRequestNo string + alipayTradeRefund = func(_ context.Context, _ *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) { + if param.OutTradeNo != "sub2_7" || param.RefundAmount != "12.50" { + t.Fatalf("unexpected refund param: %+v", param) + } + gotOutRequestNo = param.OutRequestNo + return &alipay.TradeRefundRsp{TradeNo: "2026101522001", FundChange: tc.fundChange}, nil + } + + provider := &Alipay{client: &alipay.Client{}} + resp, err := provider.Refund(context.Background(), payment.RefundRequest{OrderID: "sub2_7", Amount: "12.50", Reason: "r"}) + if err != nil { + t.Fatalf("Refund() error = %v", err) + } + if resp.Status != tc.wantStatus { + t.Fatalf("fund_change=%s status = %q, want %q", tc.fundChange, resp.Status, tc.wantStatus) + } + if !strings.HasPrefix(gotOutRequestNo, "sub2_7-refund-") || resp.RefundID != gotOutRequestNo { + t.Fatalf("RefundID = %q, out_request_no = %q", resp.RefundID, gotOutRequestNo) + } + } +} + +func TestAlipayQueryRefundMapsStatus(t *testing.T) { + origQuery := alipayTradeFastPayRefundQuery + t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery }) + + const outRequestNo = "sub2_7-refund-1760500000000000000" + ok := alipay.Error{Code: alipay.CodeSuccess, Msg: "Success"} + settled := time.Now().Add(-alipayRefundQueryNotFoundGrace - time.Minute) + for _, tc := range []struct { + name string + rsp *alipay.TradeFastPayRefundQueryRsp + requestedAt time.Time + wantStatus string + }{ + {name: "success", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50", RefundStatus: alipayRefundStatusSucc}, requestedAt: settled, wantStatus: payment.ProviderStatusSuccess}, + {name: "record without final status", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50"}, requestedAt: settled, wantStatus: payment.ProviderStatusPending}, + {name: "no refund record after grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: settled, wantStatus: payment.ProviderStatusFailed}, + {name: "no refund record within grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: time.Now().Add(-time.Minute), wantStatus: payment.ProviderStatusPending}, + {name: "no refund record with unknown request time", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, wantStatus: payment.ProviderStatusPending}, + } { + t.Run(tc.name, func(t *testing.T) { + alipayTradeFastPayRefundQuery = func(_ context.Context, _ *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + if param.OutTradeNo != "sub2_7" || param.OutRequestNo != outRequestNo { + t.Fatalf("unexpected query param: %+v", param) + } + return tc.rsp, nil + } + provider := &Alipay{client: &alipay.Client{}} + resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: outRequestNo, RequestedAt: tc.requestedAt}) + if err != nil { + t.Fatalf("QueryRefund() error = %v", err) + } + if resp.Status != tc.wantStatus || resp.RefundID != outRequestNo { + t.Fatalf("QueryRefund() = %+v, want status %q", resp, tc.wantStatus) + } + }) + } +} + +// SDK 对带签名的业务失败响应返回 err == nil,错误码只在响应体里。 +func TestAlipayQueryRefundBusinessFailureKeepsRefundPending(t *testing.T) { + origQuery := alipayTradeFastPayRefundQuery + t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery }) + + for _, rspErr := range []alipay.Error{ + {Code: "40004", Msg: "Business Failed", SubCode: "ACQ.SYSTEM_ERROR", SubMsg: "系统错误"}, + {Code: "20000", Msg: "Service Currently Unavailable", SubCode: "isp.unknow-error"}, + {}, + } { + alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + return &alipay.TradeFastPayRefundQueryRsp{Error: rspErr}, nil + } + provider := &Alipay{client: &alipay.Client{}} + resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{ + OrderID: "sub2_7", + RefundID: "sub2_7-refund-1", + RequestedAt: time.Now().Add(-24 * time.Hour), + }) + if err == nil { + t.Fatalf("code %q: QueryRefund() = %+v, want error", rspErr.Code, resp) + } + if rspErr.SubCode != "" && !strings.Contains(err.Error(), string(rspErr.Code)) { + t.Fatalf("code %q: error %q should carry the gateway code", rspErr.Code, err) + } + } +} + +func TestAlipayQueryRefundErrorsKeepRefundPending(t *testing.T) { + origQuery := alipayTradeFastPayRefundQuery + t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery }) + + calls := 0 + alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + calls++ + return nil, errors.New("ACQ.SYSTEM_ERROR") + } + provider := &Alipay{client: &alipay.Client{}} + + // 旧版本记录的是支付宝交易号,无法查询退款请求,需人工核实,且不能调用网关。 + for _, legacyID := range []string{"", "2026101522001", "sub2_7-refund", "sub2_70-refund-1"} { + if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: legacyID}); err == nil || !strings.Contains(err.Error(), "verify manually") { + t.Fatalf("legacy refund id %q: err = %v", legacyID, err) + } + } + if calls != 0 { + t.Fatalf("legacy refund ids must not reach the gateway, calls = %d", calls) + } + + if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: "sub2_7-refund-1"}); err == nil { + t.Fatal("gateway error must be returned so the refund stays pending") + } +} diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index bbb007bb3e..76469a2b36 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -7,8 +7,10 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" + "math" "net/http" "net/url" "sort" @@ -27,6 +29,7 @@ const ( easypayHTTPTimeout = 10 * time.Second maxEasypayResponseSize = 1 << 20 // 1MB maxEasypayErrorSummary = 512 + easypayAmountTolerance = 0.005 tradeStatusSuccess = "TRADE_SUCCESS" signTypeMD5 = "MD5" paymentModePopup = "popup" @@ -301,32 +304,86 @@ func (e *EasyPay) upstreamPaymentType(paymentType string) string { } func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { + result, err := e.queryOrder(ctx, tradeNo) + if err != nil { + return nil, err + } + responseTradeNo := tradeNo + if result.tradeNo != "" { + responseTradeNo = result.tradeNo + } + amount, _ := strconv.ParseFloat(result.money, 64) + return &payment.QueryOrderResponse{ + TradeNo: responseTradeNo, + Status: result.status, + Amount: amount, + Metadata: e.MerchantIdentityMetadata(), + }, nil +} + +// easyPayQueryResult is the normalized api.php?act=order answer. tradeNo is +// the upstream trade number exactly as returned (empty when absent). +type easyPayQueryResult struct { + status string + money string + tradeNo string +} + +func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQueryResult, error) { params := map[string]string{ "act": "order", "pid": e.config["pid"], - "key": e.config["pkey"], "out_trade_no": tradeNo, + "key": e.config["pkey"], "out_trade_no": outTradeNo, } - body, err := e.post(ctx, e.apiBase()+"/api.php", params) + // 彩虹易支付 and ZPay read act=order parameters from $_GET only (a + // body-only request is answered with "No Act!"), while other forks read + // the form body; send them in both places. + query := url.Values{} + for k, v := range params { + query.Set(k, v) + } + body, err := e.post(ctx, e.apiBase()+"/api.php?"+query.Encode(), params) if err != nil { + // The request URL carries the merchant key; never let it reach logs + // or audit records through *url.Error. + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } return nil, fmt.Errorf("easypay query: %w", err) } + // Scalars are decoded leniently: PHP deployments commonly serialize + // integer columns as strings ("status":"1"). type easyPayQueryData struct { - TradeStatus *string `json:"trade_status"` - Status *int `json:"status"` - Money *string `json:"money"` - TradeNo *string `json:"trade_no"` + TradeStatus *string `json:"trade_status"` + Status json.RawMessage `json:"status"` + Money json.RawMessage `json:"money"` + TradeNo json.RawMessage `json:"trade_no"` + OutTradeNo json.RawMessage `json:"out_trade_no"` } var resp struct { - Code int `json:"code"` - Msg string `json:"msg"` - TradeStatus *string `json:"trade_status"` - Status *int `json:"status"` - Money *string `json:"money"` - TradeNo *string `json:"trade_no"` - Data easyPayQueryData `json:"data"` + easyPayQueryData + Code any `json:"code"` + Msg string `json:"msg"` + Data easyPayQueryData `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("easypay parse query: %w", err) } + // An explicit failure code wins over any paid-looking status field that + // may accompany it. + if resp.Code != nil && !easyPayResponseCodeIsSuccess(resp.Code) { + msg := strings.TrimSpace(resp.Msg) + if msg == "" { + msg = summarizeEasyPayResponse(body) + } + return nil, fmt.Errorf("easypay query failed: %s", msg) + } + // When the upstream echoes the order identity it must be this order; some + // implementations silently ignore unknown lookup parameters. + if echoed := easyPayJSONScalar(pickEasyPayJSON(resp.OutTradeNo, resp.Data.OutTradeNo)); echoed != "" && echoed != strings.TrimSpace(outTradeNo) { + return nil, fmt.Errorf("easypay query out_trade_no mismatch: expected %s, got %s", strings.TrimSpace(outTradeNo), echoed) + } + status := payment.ProviderStatusPending if resp.TradeStatus != nil { if *resp.TradeStatus == tradeStatusSuccess { @@ -336,38 +393,74 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer if *resp.Data.TradeStatus == tradeStatusSuccess { status = payment.ProviderStatusPaid } - } else if resp.Status != nil { - if *resp.Status == easypayStatusPaid { + } else if raw := pickEasyPayJSON(resp.Status, resp.Data.Status); raw != nil { + if easyPayJSONScalar(raw) == strconv.Itoa(easypayStatusPaid) { status = payment.ProviderStatusPaid } - } else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid { - status = payment.ProviderStatusPaid } - money := "" - if resp.Money != nil { - money = *resp.Money - } else if resp.Data.Money != nil { - money = *resp.Data.Money - } - responseTradeNo := tradeNo - if resp.TradeNo != nil { - if *resp.TradeNo != "" { - responseTradeNo = *resp.TradeNo - } - } else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" { - responseTradeNo = *resp.Data.TradeNo - } - - amount, _ := strconv.ParseFloat(money, 64) - return &payment.QueryOrderResponse{ - TradeNo: responseTradeNo, - Status: status, - Amount: amount, - Metadata: e.MerchantIdentityMetadata(), + return &easyPayQueryResult{ + status: status, + money: easyPayJSONScalar(pickEasyPayJSON(resp.Money, resp.Data.Money)), + tradeNo: easyPayJSONScalar(pickEasyPayJSON(resp.TradeNo, resp.Data.TradeNo)), }, nil } +// ConfirmNotification checks a verified success notification against the +// upstream order before it is fulfilled, so crediting follows the gateway's +// own order state (paid, amount, trade_no). The query is sent to this +// instance's own apiBase with its own credentials. +func (e *EasyPay) ConfirmNotification(ctx context.Context, n *payment.PaymentNotification) error { + if n == nil || n.Status != payment.NotificationStatusSuccess { + return nil + } + result, err := e.queryOrder(ctx, n.OrderID) + if err != nil { + return fmt.Errorf("easypay confirm: %w", err) + } + if result.status != payment.ProviderStatusPaid { + return fmt.Errorf("easypay confirm: upstream reports order %s as not paid", n.OrderID) + } + queriedAmount, err := strconv.ParseFloat(result.money, 64) + if err != nil || math.Abs(queriedAmount-n.Amount) > easypayAmountTolerance { + return fmt.Errorf("easypay confirm: amount mismatch: notified %v, upstream %q", n.Amount, result.money) + } + if result.tradeNo != "" { + if n.TradeNo != "" && n.TradeNo != result.tradeNo { + return fmt.Errorf("easypay confirm: trade_no mismatch: notified %s, upstream %s", n.TradeNo, result.tradeNo) + } + n.TradeNo = result.tradeNo + } + return nil +} + +func easyPayJSONScalar(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var str string + if err := json.Unmarshal(raw, &str); err == nil { + return strings.TrimSpace(str) + } + var num json.Number + if err := json.Unmarshal(raw, &num); err == nil { + return num.String() + } + return "" +} + +// pickEasyPayJSON returns the top-level field when present, otherwise the +// nested data field. JSON null counts as absent. +func pickEasyPayJSON(top, nested json.RawMessage) json.RawMessage { + if len(top) > 0 && string(top) != "null" { + return top + } + if len(nested) > 0 && string(nested) != "null" { + return nested + } + return nil +} + func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) { values, err := url.ParseQuery(rawBody) if err != nil { @@ -379,7 +472,14 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st if !easyPayNotifyAllowedParams[k] { return nil, fmt.Errorf("unexpected notify param: %s", k) } - params[k] = values.Get(k) + v := values.Get(k) + // Notify values are plain tokens; only the merchant-defined product + // name is free text. Delimiter characters elsewhere are rejected so + // each value maps to exactly one field of the signed string. + if k != "name" && strings.ContainsAny(v, "&=") { + return nil, fmt.Errorf("invalid notify param value: %s", k) + } + params[k] = v } sign := params["sign"] if sign == "" { @@ -388,22 +488,20 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st if !easyPayVerifySign(params, e.config["pkey"], sign) { return nil, fmt.Errorf("invalid signature") } + // Bind the notification to this instance's merchant directly instead of + // relying on the order snapshot, which legacy orders do not carry. + if expected, actual := strings.TrimSpace(e.config["pid"]), strings.TrimSpace(params["pid"]); actual != expected { + return nil, fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual) + } status := payment.ProviderStatusFailed if params["trade_status"] == tradeStatusSuccess { status = payment.ProviderStatusSuccess } amount, _ := strconv.ParseFloat(params["money"], 64) - metadata := e.MerchantIdentityMetadata() - if pid := strings.TrimSpace(params["pid"]); pid != "" { - if metadata == nil { - metadata = map[string]string{} - } - metadata["pid"] = pid - } return &payment.PaymentNotification{ TradeNo: params["trade_no"], OrderID: params["out_trade_no"], - Amount: amount, Status: status, RawData: rawBody, Metadata: metadata, + Amount: amount, Status: status, RawData: rawBody, Metadata: e.MerchantIdentityMetadata(), }, nil } diff --git a/backend/internal/payment/provider/easypay_confirm_test.go b/backend/internal/payment/provider/easypay_confirm_test.go new file mode 100644 index 0000000000..9463aa7669 --- /dev/null +++ b/backend/internal/payment/provider/easypay_confirm_test.go @@ -0,0 +1,253 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +func newConfirmTestEasyPay(t *testing.T, body string, status int) (*EasyPay, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + } + q := r.URL.Query() + if r.URL.Path != "/api.php" || q.Get("act") != "order" || q.Get("pid") != "1001" || q.Get("key") != "pkey-1" || q.Get("out_trade_no") != "sub2_ORDER1" { + t.Errorf("unexpected query request: path=%s query=%v", r.URL.Path, q) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + provider, err := NewEasyPay("test-instance", map[string]string{ + "pid": "1001", + "pkey": "pkey-1", + "apiBase": server.URL, + "notifyUrl": "https://example.com/notify", + "returnUrl": "https://example.com/return", + }) + if err != nil { + t.Fatalf("NewEasyPay: %v", err) + } + return provider, &calls +} + +func confirmTestNotification() *payment.PaymentNotification { + return &payment.PaymentNotification{ + TradeNo: "2026101600001", + OrderID: "sub2_ORDER1", + Amount: 10, + Status: payment.NotificationStatusSuccess, + } +} + +func TestEasyPayConfirmNotification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + status int + notifyTrade string + wantErr string + wantTradeNo string + }{ + { + name: "paid with matching amount and trade_no", + body: `{"code":1,"pid":1001,"out_trade_no":"sub2_ORDER1","trade_no":"2026101600001","money":"10.00","status":1}`, + notifyTrade: "2026101600001", + wantTradeNo: "2026101600001", + }, + { + name: "missing notify trade_no is filled from upstream", + body: `{"code":1,"trade_no":"2026101600001","money":"10.00","trade_status":"TRADE_SUCCESS"}`, + wantTradeNo: "2026101600001", + }, + { + name: "upstream not paid", + body: `{"code":1,"trade_no":"2026101600001","money":"10.00","status":0}`, + notifyTrade: "2026101600001", + wantErr: "not paid", + }, + { + name: "upstream failure code with paid status", + body: `{"code":-1,"msg":"订单号不存在","status":1,"money":"10.00"}`, + notifyTrade: "2026101600001", + wantErr: "订单号不存在", + }, + { + name: "amount mismatch", + body: `{"code":1,"trade_no":"2026101600001","money":"0.01","status":1}`, + notifyTrade: "2026101600001", + wantErr: "amount mismatch", + }, + { + name: "missing upstream amount", + body: `{"code":1,"trade_no":"2026101600001","status":1}`, + notifyTrade: "2026101600001", + wantErr: "amount mismatch", + }, + { + name: "trade_no conflict", + body: `{"code":1,"trade_no":"2026101699999","money":"10.00","status":1}`, + notifyTrade: "2026101600001", + wantErr: "trade_no mismatch", + }, + { + name: "string status from PHP deployment", + body: `{"code":"1","pid":"1001","out_trade_no":"sub2_ORDER1","trade_no":"2026101600001","money":"10.00","status":"1"}`, + notifyTrade: "2026101600001", + wantTradeNo: "2026101600001", + }, + { + name: "foreign order echo", + body: `{"code":1,"out_trade_no":"sub2_OTHER","trade_no":"2026101600001","money":"10.00","status":1}`, + notifyTrade: "2026101600001", + wantErr: "out_trade_no mismatch", + }, + { + name: "upstream HTTP error body", + body: `502 Bad Gateway`, + status: http.StatusBadGateway, + notifyTrade: "2026101600001", + wantErr: "parse query", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + status := tt.status + if status == 0 { + status = http.StatusOK + } + provider, calls := newConfirmTestEasyPay(t, tt.body, status) + n := confirmTestNotification() + n.TradeNo = tt.notifyTrade + err := provider.ConfirmNotification(context.Background(), n) + if calls.Load() != 1 { + t.Fatalf("upstream query calls = %d, want 1", calls.Load()) + } + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ConfirmNotification error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ConfirmNotification returned error: %v", err) + } + if n.TradeNo != tt.wantTradeNo { + t.Fatalf("trade_no = %q, want %q", n.TradeNo, tt.wantTradeNo) + } + }) + } +} + +func TestEasyPayConfirmNotificationSkipsNonSuccess(t *testing.T) { + t.Parallel() + + provider, calls := newConfirmTestEasyPay(t, `{}`, http.StatusOK) + n := confirmTestNotification() + n.Status = payment.ProviderStatusFailed + if err := provider.ConfirmNotification(context.Background(), n); err != nil { + t.Fatalf("ConfirmNotification returned error: %v", err) + } + if calls.Load() != 0 { + t.Fatalf("upstream query calls = %d, want 0", calls.Load()) + } +} + +func TestEasyPayConfirmNotificationQueryTransportError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + apiBase := server.URL + server.Close() + provider := newTestEasyPay(t, apiBase) + err := provider.ConfirmNotification(context.Background(), confirmTestNotification()) + if err == nil { + t.Fatal("ConfirmNotification must fail when the upstream is unreachable") + } + // The query URL carries the merchant key; errors are logged and stored in + // the payment audit trail, so they must not echo the URL. + if strings.Contains(err.Error(), "pkey-1") || strings.Contains(err.Error(), "key=") { + t.Fatalf("error contains the merchant key: %v", err) + } +} + +func TestEasyPayNotifyRejectsForeignPid(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + for _, pid := range []string{"2000", ""} { + params := map[string]string{ + "trade_no": "T1", + "out_trade_no": "ORDER123", + "type": "alipay", + "name": "balance recharge", + "money": "650.00", + "trade_status": tradeStatusSuccess, + } + if pid != "" { + params["pid"] = pid + } + params["sign"] = easyPaySign(params, e.config["pkey"]) + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil { + t.Fatalf("callback with pid %q must be rejected", pid) + } + } +} + +func TestEasyPayNotifyAllowsFreeTextNameOnly(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + base := map[string]string{ + "pid": "1000", + "trade_no": "T1", + "out_trade_no": "ORDER123", + "type": "alipay", + "name": "Plan A&B = Pro", + "money": "650.00", + "trade_status": tradeStatusSuccess, + } + encode := func(params map[string]string) string { + signed := map[string]string{} + for k, v := range params { + signed[k] = v + } + signed["sign"] = easyPaySign(params, e.config["pkey"]) + q := url.Values{} + for k, v := range signed { + q.Set(k, v) + } + return q.Encode() + } + if _, err := e.VerifyNotification(context.Background(), encode(base), nil); err != nil { + t.Fatalf("free-text name must be accepted: %v", err) + } + for _, field := range []string{"trade_no", "out_trade_no", "type", "money", "param"} { + params := map[string]string{} + for k, v := range base { + params[k] = v + } + params[field] = "v&k=x" + if _, err := e.VerifyNotification(context.Background(), encode(params), nil); err == nil { + t.Fatalf("value with &/= in %s must be rejected", field) + } + } +} diff --git a/backend/internal/payment/provider/easypay_query_test.go b/backend/internal/payment/provider/easypay_query_test.go index 5042a94d25..b6a3c6e2ee 100644 --- a/backend/internal/payment/provider/easypay_query_test.go +++ b/backend/internal/payment/provider/easypay_query_test.go @@ -20,6 +20,7 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { wantStatus string wantTradeNo string wantAmount float64 + wantErr bool }{ { name: "top level trade success is paid", @@ -64,10 +65,64 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { wantAmount: 3.21, }, { - name: "query failure with missing status is pending", - body: `{"code":0,"msg":"订单不存在"}`, + name: "query failure code is an error", + body: `{"code":0,"msg":"订单不存在"}`, + wantErr: true, + }, + { + name: "explicit failure code wins over paid status fields", + body: `{"code":-1,"msg":"签名错误","status":1,"trade_status":"TRADE_SUCCESS","money":"12.34"}`, + wantErr: true, + }, + { + name: "string failure code is an error", + body: `{"code":"-1","msg":"error","status":1}`, + wantErr: true, + }, + { + name: "foreign order echo is rejected", + body: `{"code":1,"pid":"pid-1","out_trade_no":"order-999","trade_status":"TRADE_SUCCESS","money":"12.34"}`, + wantErr: true, + }, + { + name: "foreign order echo in nested data is rejected", + body: `{"code":1,"data":{"out_trade_no":"order-999","trade_status":"TRADE_SUCCESS","money":"12.34"}}`, + wantErr: true, + }, + { + name: "string numeric status from PHP deployments is paid", + body: `{"code":"1","msg":"查询订单号成功!","trade_no":"2026101600001","out_trade_no":"order-123","pid":"1001","money":"12.34","status":"1"}`, + wantStatus: payment.ProviderStatusPaid, + wantTradeNo: "2026101600001", + wantAmount: 12.34, + }, + { + name: "string numeric unpaid status is pending", + body: `{"code":1,"trade_no":"2026101600001","money":"12.34","status":"0"}`, wantStatus: payment.ProviderStatusPending, - wantTradeNo: orderID, + wantTradeNo: "2026101600001", + wantAmount: 12.34, + }, + { + name: "documented caihong response with integer pid and status is paid", + body: `{"code":1,"msg":"查询订单号成功!","trade_no":"2016080622555342651","out_trade_no":"order-123","api_trade_no":"4200001","type":"alipay","pid":1001,"addtime":"2016-08-06 22:55:52","endtime":"2016-08-06 22:55:52","name":"VIP会员","money":"1.00","status":1,"param":"","buyer":""}`, + wantStatus: payment.ProviderStatusPaid, + wantTradeNo: "2016080622555342651", + wantAmount: 1, + }, + { + name: "null top-level fields fall back to nested data", + body: `{"code":1,"status":null,"money":null,"trade_no":null,"data":{"status":1,"money":"5.00","trade_no":"data-789"}}`, + wantStatus: payment.ProviderStatusPaid, + wantTradeNo: "data-789", + wantAmount: 5, + }, + { + name: "matching identity echo with numeric fields is paid", + body: `{"code":"1","pid":"pid-1","out_trade_no":"order-123","trade_status":"TRADE_SUCCESS","money":12.34,"trade_no":2026101600001}`, + wantStatus: payment.ProviderStatusPaid, + wantTradeNo: "2026101600001", + wantAmount: 12.34, }, { name: "missing fields are pending", @@ -93,9 +148,16 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { if err := r.ParseForm(); err != nil { t.Errorf("ParseForm: %v", err) } - gotForm = make(url.Values, len(r.PostForm)) + // Parameters must be in the URL query (彩虹易支付 reads $_GET) + // and mirrored in the body for forks that read $_POST. + gotForm = r.URL.Query() for key, values := range r.PostForm { - gotForm[key] = append([]string(nil), values...) + if got := gotForm.Get(key); got != values[0] { + t.Errorf("body[%s] = %q, query has %q", key, values[0], got) + } + } + if len(r.PostForm) != len(gotForm) { + t.Errorf("body params %v do not mirror query params %v", r.PostForm, gotForm) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(tt.body)) @@ -104,6 +166,12 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { provider := newTestEasyPay(t, server.URL) resp, err := provider.QueryOrder(context.Background(), orderID) + if tt.wantErr { + if err == nil { + t.Fatalf("QueryOrder succeeded, want error (response=%+v)", resp) + } + return + } if err != nil { t.Fatalf("QueryOrder returned error: %v", err) } diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 6421c4afed..dc3ec58ff3 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -2,7 +2,10 @@ // registry, load balancing, and shared utilities for the payment subsystem. package payment -import "context" +import ( + "context" + "time" +) // PaymentType represents a supported payment method. type PaymentType = string @@ -192,6 +195,8 @@ type RefundQueryRequest struct { OrderID string RefundID string Amount string + // RequestedAt 为退款进入 pending 的时间;零值表示未知。 + RequestedAt time.Time } // RefundResponse is returned after a refund request. @@ -234,6 +239,16 @@ type RefundQueryProvider interface { QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error) } +// NotificationConfirmer extends Provider for gateways whose verified +// notifications are not sufficient proof of payment on their own. The webhook +// path calls ConfirmNotification on the instance that verified the +// notification before fulfillment; implementations query the upstream order +// and may fill in missing notification fields (e.g. TradeNo) from it. +type NotificationConfirmer interface { + Provider + ConfirmNotification(ctx context.Context, n *PaymentNotification) error +} + // CancelableProvider extends Provider with the ability to cancel pending payments. type CancelableProvider interface { Provider diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index bb46a5e48e..a54ca94133 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -13,6 +13,12 @@ import ( // Chat Completions intermediary round-trip (e.g. thinking, cache_control, // structured system prompts). func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { + return AnthropicToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// AnthropicToResponsesWithMinOutputTokens converts an Anthropic request using +// the supplied max_output_tokens floor. +func AnthropicToResponsesWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ResponsesRequest, error) { if err := openai.ValidateGPT61SolReasoningEffort(req.Model, anthropicReasoningEffort(req)); err != nil { return nil, err } @@ -50,8 +56,8 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { if req.MaxTokens > 0 { v := req.MaxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index fc73afa3a4..20a590c1c6 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -40,6 +40,12 @@ import ( // ResponsesToChatCompletionsRequest but avoids materializing the intermediate // ResponsesRequest and the extra marshal/unmarshal cycle. func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsRequest, error) { + return AnthropicToChatCompletionsRequestWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// AnthropicToChatCompletionsRequestWithMinOutputTokens converts an Anthropic +// Messages request using the supplied max_completion_tokens floor. +func AnthropicToChatCompletionsRequestWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ChatCompletionsRequest, error) { if req == nil { return nil, fmt.Errorf("anthropic request is nil") } @@ -63,8 +69,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR if req.MaxTokens > 0 { v := req.MaxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxCompletionTokens = &v } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index b278c28a83..13cc945ca7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -267,6 +267,43 @@ func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) { require.Equal(t, minMaxOutputTokens, *out.MaxCompletionTokens) } +func TestAnthropicToChatCompletionsRequestWithMinOutputTokens_ConfiguredFloor(t *testing.T) { + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: 16, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + + out, err := AnthropicToChatCompletionsRequestWithMinOutputTokens(req, 64) + require.NoError(t, err) + require.NotNil(t, out.MaxCompletionTokens) + require.Equal(t, 64, *out.MaxCompletionTokens) +} + +func TestAnthropicToResponses_ConfiguredMaxTokensFloor(t *testing.T) { + for _, input := range []int{16, 64, 127} { + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: input, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + out, err := AnthropicToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, out.MaxOutputTokens) + require.Equal(t, input, *out.MaxOutputTokens) + } + + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: 1, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + out, err := AnthropicToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, out.MaxOutputTokens) + require.Equal(t, 16, *out.MaxOutputTokens) +} + func TestAnthropicToChatCompletionsRequest_ReasoningEffortMapping(t *testing.T) { req := &AnthropicRequest{ Model: "gpt-5.4", diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 1a27ef2374..b94bc5c394 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -2,6 +2,7 @@ package apicompat import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -290,6 +291,46 @@ func mustMarshalJSON(t *testing.T, value any) []byte { } func TestChatCompletionsToResponses_MaxTokens(t *testing.T) { + t.Run("default floor", func(t *testing.T) { + maxTokens := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &maxTokens, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, DefaultMinMaxOutputTokens, *resp.MaxOutputTokens) + }) + + t.Run("configured floor", func(t *testing.T) { + for _, input := range []int{16, 64, 127, 128} { + input := input + t.Run(fmt.Sprintf("input_%d", input), func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &input, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, input, *resp.MaxOutputTokens) + }) + } + input := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &input, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, 16, *resp.MaxOutputTokens) + }) + t.Run("max_tokens", func(t *testing.T) { maxTokens := 100 req := &ChatCompletionsRequest{ @@ -318,6 +359,21 @@ func TestChatCompletionsToResponses_MaxTokens(t *testing.T) { require.NotNil(t, resp.MaxOutputTokens) assert.Equal(t, 500, *resp.MaxOutputTokens) }) + + t.Run("max_completion_tokens_preferred before configured floor", func(t *testing.T) { + maxTokens := 500 + maxCompletion := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &maxTokens, + MaxCompletionTokens: &maxCompletion, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, 16, *resp.MaxOutputTokens) + }) } func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 5f4267c831..9f221a064a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -18,6 +18,12 @@ type chatMessageContent struct { // true. store is always false and reasoning.encrypted_content is always // included so that the response translator has full context. func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, error) { + return ChatCompletionsToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// ChatCompletionsToResponsesWithMinOutputTokens converts a Chat Completions +// request using the supplied max_output_tokens floor. +func ChatCompletionsToResponsesWithMinOutputTokens(req *ChatCompletionsRequest, minOutputTokens int) (*ResponsesRequest, error) { if err := openai.ValidateGPT61SolReasoningEffort(req.Model, req.ReasoningEffort); err != nil { return nil, err } @@ -62,8 +68,8 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } if maxTokens > 0 { v := maxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b5864f2f2f..956101bd73 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -860,6 +860,9 @@ func (d ChatDelta) reasoningText() *string { // Shared constants // --------------------------------------------------------------------------- -// minMaxOutputTokens is the floor for max_output_tokens in a Responses request. -// Very small values may cause upstream API errors, so we enforce a minimum. -const minMaxOutputTokens = 128 +// DefaultMinMaxOutputTokens is the default floor for max_output_tokens in a +// Responses request. Very small values may cause upstream API errors, so we +// enforce a minimum. +const DefaultMinMaxOutputTokens = 128 + +const minMaxOutputTokens = DefaultMinMaxOutputTokens diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index b62e32c842..60ecd977ed 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -71,7 +71,7 @@ func (s *AntigravityGatewayService) ForwardAsChatCompletions( return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", "model is required") } - responsesRequest, err := apicompat.ChatCompletionsToResponses(&request) + responsesRequest, err := chatCompletionsToResponses(settingServiceConfig(s.settingService), &request) if err != nil { return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } diff --git a/backend/internal/service/apicompat_config.go b/backend/internal/service/apicompat_config.go new file mode 100644 index 0000000000..e9605e0a5c --- /dev/null +++ b/backend/internal/service/apicompat_config.go @@ -0,0 +1,28 @@ +package service + +import ( + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +func configuredMinOutputTokens(cfg *config.Config) int { + if cfg != nil && cfg.Gateway.MinOutputTokens >= 16 { + return cfg.Gateway.MinOutputTokens + } + return apicompat.DefaultMinMaxOutputTokens +} + +func chatCompletionsToResponses(cfg *config.Config, req *apicompat.ChatCompletionsRequest) (*apicompat.ResponsesRequest, error) { + return apicompat.ChatCompletionsToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg)) +} + +func anthropicToResponses(cfg *config.Config, req *apicompat.AnthropicRequest) (*apicompat.ResponsesRequest, error) { + return apicompat.AnthropicToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg)) +} + +func settingServiceConfig(settingService *SettingService) *config.Config { + if settingService == nil { + return nil + } + return settingService.cfg +} diff --git a/backend/internal/service/gateway_anthropic_base_url_test.go b/backend/internal/service/gateway_anthropic_base_url_test.go new file mode 100644 index 0000000000..490c01cc82 --- /dev/null +++ b/backend/internal/service/gateway_anthropic_base_url_test.go @@ -0,0 +1,106 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func gatewayAnthropicBaseURLAccount(apiBaseURLs map[string]any) *Account { + return &Account{ + ID: 8074, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "base_url": "https://api.deepseek.com", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": apiBaseURLs, + }, + } +} + +func gatewayAnthropicBaseURLContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + return c +} + +func TestGatewayService_AdaptiveAnthropicBaseURLRouting(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{}} + body := []byte(`{"model":"deepseek-chat","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + + t.Run("base present is used by messages and count_tokens", func(t *testing.T) { + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolAnthropic: "https://anthropic.example.com", + APIProtocolChatCompletions: "https://chat.example.com", + }) + c := gatewayAnthropicBaseURLContext(t) + + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://anthropic.example.com/v1/messages?beta=true", req.URL.String()) + + countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false) + require.NoError(t, err) + require.Equal(t, "https://anthropic.example.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + }) + + t.Run("missing anthropic base uses provider fallback", func(t *testing.T) { + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolChatCompletions: "https://chat.example.com", + }) + req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages?beta=true", req.URL.String()) + }) + + t.Run("ordinary anthropic api key keeps base_url fallback", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "base_url": "https://custom-anthropic.example.com", + }, + } + req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://custom-anthropic.example.com/v1/messages?beta=true", req.URL.String()) + }) +} + +func TestGatewayService_AnthropicPassthroughAndCountTokensUseSelectedBase(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{}} + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + }) + body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}]}`) + c := gatewayAnthropicBaseURLContext(t) + + passthroughReq, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key") + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", passthroughReq.URL.String()) + require.Equal(t, "Bearer test-key", passthroughReq.Header.Get("Authorization")) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + countPassthroughReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key") + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countPassthroughReq.URL.String()) + require.Equal(t, "Bearer test-key", countPassthroughReq.Header.Get("Authorization")) + + countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-v4-flash", false) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + require.Equal(t, "Bearer test-key", countReq.Header.Get("Authorization")) +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index b998f69eeb..889e2faa36 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -301,7 +301,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( ) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) targetURL := claudeAPIURL - baseURL := account.GetBaseURL() + baseURL := getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -326,8 +326,8 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( } // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 - // base 取值同源(GetBaseURL),详见 helper 注释。 - body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + // base 取值同源,详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { @@ -354,7 +354,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("cookie") // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 95908a3f44..a6a493e25e 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -399,7 +399,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( ) (*http.Request, error) { body = stripDeferredToolCacheControl(body) targetURL := claudeAPICountTokensURL - baseURL := account.GetBaseURL() + baseURL := getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -446,7 +446,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( req.Header.Del("cookie") // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -467,8 +467,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con body = stripDeferredToolCacheControl(body) // 确定目标 URL targetURL := claudeAPICountTokensURL + baseURL := "" if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() + baseURL = getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -558,7 +559,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) } // 白名单透传 headers(恢复真实 wire casing) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 60b14488cd..83d82e6ed0 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -44,7 +44,7 @@ func (s *GatewayService) ForwardAsChatCompletions( clientStream := ccReq.Stream // 2. Convert CC → Responses → Anthropic (chained conversion) - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index db8033ebe4..189c086192 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -37,7 +37,7 @@ func (s *GatewayService) ForwardAsResponses( ) (*ForwardResult, error) { startTime := time.Now() - normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body) + normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body) if err != nil { return nil, err } diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index a7c899ef34..7634f7fe18 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -58,8 +58,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 确定目标URL targetURL := claudeAPIURL + baseURL := "" if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() + baseURL = getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -155,9 +156,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 - // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型 + // base 取值同源,仅实际上游为 ollama.com 且映射后出站模型 // 为 DeepSeek 系时压到 cap,详见 helper 注释。 - body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body) req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { @@ -170,7 +171,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) } // 白名单透传 headers @@ -256,6 +257,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex return req, body, nil } +func getAnthropicGatewayBaseURL(account *Account) string { + if account == nil { + return "" + } + if baseURL := account.GetAnthropicProtocolBaseURL(); baseURL != "" { + return baseURL + } + return account.GetBaseURL() +} + // vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta // 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的 // bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未 diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 6168d9a731..f85ac0577a 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -51,7 +51,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( } } - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index b7341ccda3..af4eae36ea 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -231,7 +231,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // Normal path: convert Chat Completions → Responses. // ChatCompletionsToResponses always sets Stream=true (upstream always streams). chatReq.Model = upstreamModel - responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) + responsesReq, err = chatCompletionsToResponses(s.cfg, &chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 6c0affe2bf..f76a457370 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -57,7 +57,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( clientStream := ccReq.Stream // 2. Convert CC → Responses → Anthropic (chained conversion) - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") return nil, fmt.Errorf("convert chat completions to responses: %w", err) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 5faa086380..27aafd997d 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -65,7 +65,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } - legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body) + legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body) if legacyIngressErr != nil { return nil, legacyIngressErr } diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 284b0dcf9f..546b3007f2 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -541,7 +541,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } - responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &chatReq) if err != nil { return nil, fmt.Errorf("convert grok chat completions to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 75c83c7138..18c4fb1372 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -144,7 +144,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 3. Convert Anthropic → Responses after compatibility-only replay guard. anthropicReq.Model = upstreamModel - responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) + responsesReq, err := anthropicToResponses(s.cfg, &anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 2d2c997b97..d12918a3bb 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -52,7 +52,10 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( clientStream := anthropicReq.Stream // 2. Anthropic → Chat Completions (direct, no Responses intermediary) - chatReq, err := apicompat.AnthropicToChatCompletionsRequest(&anthropicReq) + chatReq, err := apicompat.AnthropicToChatCompletionsRequestWithMinOutputTokens( + &anthropicReq, + configuredMinOutputTokens(s.cfg), + ) if err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, fmt.Errorf("convert anthropic to chat completions: %w", err) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index bf74b242d9..6feadb8fb6 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -214,6 +214,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if account != nil && account.IsOpenAI() { responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ + Config: s.cfg, ResponsesLite: responsesLite, Compact: isOpenAIResponsesCompactPath(c), }) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 7ae18793e7..52ca1d57b4 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -1433,7 +1434,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ResponsesLite: responsesLite}) } +func normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(cfg *config.Config, body []byte, account *Account, responsesLite bool) ([]byte, bool, error) { + return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{Config: cfg, ResponsesLite: responsesLite}) +} + type openAIResponsesCompatibilityOptions struct { + Config *config.Config ResponsesLite bool // Compact marks the /responses/compact wire shape, which is left as-is // by request-shape compatibility rewrites such as web_search history. @@ -1449,7 +1455,7 @@ func normalizeOpenAIResponsesCompatibilityBodyWithOptions(body []byte, account * changed := false if account.IsOpenAIOAuthLike() { var err error - normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body) + normalized, changed, err = normalizeOpenAIResponsesLegacyIngressWithConfig(opts.Config, body) if err != nil { return body, false, err } diff --git a/backend/internal/service/openai_responses_ingress_compat.go b/backend/internal/service/openai_responses_ingress_compat.go index b5a289dd3e..d7a2adafb8 100644 --- a/backend/internal/service/openai_responses_ingress_compat.go +++ b/backend/internal/service/openai_responses_ingress_compat.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/tidwall/gjson" ) @@ -13,6 +14,10 @@ import ( // payloads that a few Responses clients still emit. Native Responses input is // always authoritative because this path has no separate full-replay attempt. func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { + return normalizeOpenAIResponsesLegacyIngressWithConfig(nil, body) +} + +func normalizeOpenAIResponsesLegacyIngressWithConfig(cfg *config.Config, body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } @@ -43,7 +48,7 @@ func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { if hasMessages { messages, messagesAreArray := messagesValue.([]any) if messagesAreArray && len(messages) > 0 { - legacy, err := convertLegacyResponsesMessages(body) + legacy, err := convertLegacyResponsesMessages(cfg, body) if err != nil { return body, false, err } @@ -105,13 +110,13 @@ type convertedLegacyResponsesMessages struct { hasReasoningEffort bool } -func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) { +func convertLegacyResponsesMessages(cfg *config.Config, body []byte) (convertedLegacyResponsesMessages, error) { var converted convertedLegacyResponsesMessages var chatRequest apicompat.ChatCompletionsRequest if err := json.Unmarshal(body, &chatRequest); err != nil { return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) } - responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest) + responsesRequest, err := chatCompletionsToResponses(cfg, &chatRequest) if err != nil { return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 09ea02276a..10bb77450d 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -269,7 +269,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = next } responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) - if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { + if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, normalized, account, responsesLite); compatibilityErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr) } else if compatibilityChanged { normalized = compatibilityBody diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 5b5f25e37f..e7d0a0d09b 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -746,7 +746,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) } else if compatibilityChanged { firstClientMessage = normalized @@ -998,7 +998,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload) if isResponseCreate { - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, payload, account, responsesLite); normalizeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) } else if compatibilityChanged { payload = normalized diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4c363d3e03..623ee22723 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -18,6 +18,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming" ) // ErrOrderNotFound is returned by HandlePaymentNotification when the webhook @@ -56,6 +57,33 @@ func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payme return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk, n.Metadata) } +// ConfirmNotificationUpstream asks providers that implement +// payment.NotificationConfirmer to confirm a verified success notification +// against the upstream order, using the same instance that verified it. +// Failures are recorded on the order's audit trail and returned so the webhook +// answers non-success and the gateway retries. +func (s *PaymentService) ConfirmNotificationUpstream(ctx context.Context, prov payment.Provider, n *payment.PaymentNotification) error { + confirmer, ok := prov.(payment.NotificationConfirmer) + if !ok || n == nil || n.Status != payment.NotificationStatusSuccess { + return nil + } + notifiedTradeNo := n.TradeNo + finishProviderCall := servertiming.ObserveDependency(ctx, "payment") + err := confirmer.ConfirmNotification(ctx, n) + finishProviderCall() + if err == nil { + return nil + } + if order, lookupErr := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx); lookupErr == nil { + s.writeAuditLog(ctx, order.ID, "PAYMENT_UPSTREAM_UNCONFIRMED", prov.ProviderKey(), map[string]any{ + "detail": err.Error(), + "tradeNo": notifiedTradeNo, + "amount": n.Amount, + }) + } + return err +} + func parseLegacyPaymentOrderID(orderID string, lookupErr error) (int64, bool) { if !dbent.IsNotFound(lookupErr) { return 0, false @@ -218,10 +246,38 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro if err != nil { return fmt.Errorf("get order: %w", err) } - if o.OrderType == payment.OrderTypeSubscription { + switch o.OrderType { + case payment.OrderTypeSubscription: return s.ExecuteSubscriptionFulfillment(ctx, oid) + case payment.OrderTypeBalance: + return s.ExecuteBalanceFulfillment(ctx, oid) + default: + // 不支持的订单类型不履约:标记 FAILED 后正常返回,避免支付回调持续 500 重试。 + s.rejectUnsupportedOrderType(ctx, o) + return nil + } +} + +func isSupportedPaymentOrderType(orderType string) bool { + return orderType == payment.OrderTypeBalance || orderType == payment.OrderTypeSubscription +} + +func (s *PaymentService) rejectUnsupportedOrderType(ctx context.Context, o *dbent.PaymentOrder) { + reason := "unsupported order type: " + o.OrderType + c, err := s.entClient.PaymentOrder.Update(). + Where( + paymentorder.IDEQ(o.ID), + paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging), + ). + SetStatus(OrderStatusFailed).SetFailedAt(time.Now()).SetFailedReason(reason).Save(ctx) + if err != nil { + slog.Error("mark unsupported order type FAILED", "orderID", o.ID, "error", err) + return + } + if c > 0 { + slog.Warn("payment order with unsupported order type not fulfilled", "orderID", o.ID, "orderType", o.OrderType) + s.writeAuditLog(ctx, o.ID, "FULFILLMENT_FAILED", "system", map[string]any{"reason": reason}) } - return s.ExecuteBalanceFulfillment(ctx, oid) } func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int64) error { @@ -229,6 +285,9 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6 if err != nil { return infraerrors.NotFound("NOT_FOUND", "order not found") } + if o.OrderType != payment.OrderTypeBalance { + return infraerrors.BadRequest("INVALID_ORDER_TYPE", "order is not a balance order") + } if o.Status == OrderStatusCompleted { return nil } @@ -885,6 +944,9 @@ func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error if o.Status != OrderStatusFailed && o.Status != OrderStatusPaid && o.Status != OrderStatusRecharging { return infraerrors.BadRequest("INVALID_STATUS", "only paid, failed, and recoverable recharging orders can retry") } + if !isSupportedPaymentOrderType(o.OrderType) { + return infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type") + } s.writeAuditLog(ctx, oid, "RECHARGE_RETRY", "admin", map[string]any{"detail": "admin manual retry"}) return s.executeFulfillment(ctx, oid) } diff --git a/backend/internal/service/payment_notification_confirm_test.go b/backend/internal/service/payment_notification_confirm_test.go new file mode 100644 index 0000000000..66bf089592 --- /dev/null +++ b/backend/internal/service/payment_notification_confirm_test.go @@ -0,0 +1,101 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strconv" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +type paymentConfirmTestProvider struct { + paymentFulfillmentTestProvider + err error + calls *int +} + +func (p paymentConfirmTestProvider) ConfirmNotification(_ context.Context, n *payment.PaymentNotification) error { + *p.calls++ + if p.err == nil && n.TradeNo == "" { + n.TradeNo = "upstream-trade" + } + return p.err +} + +func TestConfirmNotificationUpstreamRecordsAuditOnFailure(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + user, err := client.User.Create(). + SetEmail("confirm-upstream@example.com"). + SetPasswordHash("hash"). + SetUsername("confirm-upstream-user"). + Save(ctx) + require.NoError(t, err) + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(10). + SetPayAmount(10). + SetFeeRate(0). + SetRechargeCode("PAY-CONFIRM-UPSTREAM"). + SetOutTradeNo("sub2_confirm_upstream"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{entClient: client} + calls := 0 + prov := paymentConfirmTestProvider{ + paymentFulfillmentTestProvider: paymentFulfillmentTestProvider{key: payment.TypeEasyPay}, + err: errors.New("easypay confirm: upstream reports order sub2_confirm_upstream as not paid"), + calls: &calls, + } + n := &payment.PaymentNotification{OrderID: order.OutTradeNo, TradeNo: "T-unconfirmed", Amount: 10, Status: payment.NotificationStatusSuccess} + + err = svc.ConfirmNotificationUpstream(ctx, prov, n) + require.Error(t, err) + require.Equal(t, 1, calls) + + count, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("PAYMENT_UPSTREAM_UNCONFIRMED")). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, count) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, reloaded.Status) +} + +func TestConfirmNotificationUpstreamPassThrough(t *testing.T) { + svc := &PaymentService{} + n := &payment.PaymentNotification{OrderID: "sub2_x", Amount: 10, Status: payment.NotificationStatusSuccess} + + // Providers without the confirmer capability keep their existing flow. + require.NoError(t, svc.ConfirmNotificationUpstream(context.Background(), paymentFulfillmentTestProvider{key: payment.TypeStripe}, n)) + + calls := 0 + prov := paymentConfirmTestProvider{paymentFulfillmentTestProvider: paymentFulfillmentTestProvider{key: payment.TypeEasyPay}, calls: &calls} + + // Non-success notifications are never fulfilled, so they are not queried. + failed := &payment.PaymentNotification{OrderID: "sub2_x", Status: payment.ProviderStatusFailed} + require.NoError(t, svc.ConfirmNotificationUpstream(context.Background(), prov, failed)) + require.Equal(t, 0, calls) + + require.NoError(t, svc.ConfirmNotificationUpstream(context.Background(), prov, n)) + require.Equal(t, 1, calls) + require.Equal(t, "upstream-trade", n.TradeNo) +} diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index becefc6a1a..3ddd9fec47 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -120,11 +120,16 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest } func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) { - if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled { - return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled") - } - if req.OrderType == payment.OrderTypeSubscription { + // 订单类型必须走白名单:未知类型会跳过充值开关与倍率计算,履约时却仍按余额到账。 + switch req.OrderType { + case payment.OrderTypeBalance: + if cfg.BalanceDisabled { + return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled") + } + case payment.OrderTypeSubscription: return s.validateSubOrder(ctx, req) + default: + return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type") } if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 { return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number") diff --git a/backend/internal/service/payment_order_type_test.go b/backend/internal/service/payment_order_type_test.go new file mode 100644 index 0000000000..e344db5de4 --- /dev/null +++ b/backend/internal/service/payment_order_type_test.go @@ -0,0 +1,80 @@ +//go:build unit + +package service + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" + "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestValidateOrderInputRejectsUnknownOrderType(t *testing.T) { + svc := &PaymentService{} + for _, orderType := range []string{"x", "Balance", " balance", "subscription "} { + for _, disabled := range []bool{false, true} { + cfg := &PaymentConfig{BalanceDisabled: disabled} + plan, err := svc.validateOrderInput(context.Background(), CreateOrderRequest{OrderType: orderType, Amount: 10}, cfg) + require.Nil(t, plan) + require.Error(t, err, "order type %q must be rejected", orderType) + require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err), "order type %q", orderType) + } + } +} + +func TestValidateOrderInputKeepsBalanceDisabledSwitch(t *testing.T) { + svc := &PaymentService{} + _, err := svc.validateOrderInput(context.Background(), CreateOrderRequest{OrderType: payment.OrderTypeBalance, Amount: 10}, &PaymentConfig{BalanceDisabled: true}) + require.Equal(t, "BALANCE_PAYMENT_DISABLED", infraerrors.Reason(err)) + + plan, err := svc.validateOrderInput(context.Background(), CreateOrderRequest{OrderType: payment.OrderTypeBalance, Amount: 10}, &PaymentConfig{}) + require.NoError(t, err) + require.Nil(t, plan) +} + +func TestFulfillmentRejectsUnknownOrderType(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType("x"). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID}} + userRepo.updateBalanceFn = func(context.Context, int64, float64) error { + t.Fatal("unknown order type must not credit balance") + return nil + } + svc := &PaymentService{entClient: client, userRepo: userRepo} + + err = svc.ExecuteBalanceFulfillment(ctx, order.ID) + require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err)) + + // 不履约,但标记 FAILED 并正常返回,使支付回调得到确认而不是无限重试。 + require.NoError(t, svc.executeFulfillment(ctx, order.ID)) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusFailed, reloaded.Status) + require.NotNil(t, reloaded.FailedReason) + require.Contains(t, *reloaded.FailedReason, "unsupported order type") + + // 回调重试经 alreadyProcessed 再次进入履约:幂等,不重复写审计。 + require.NoError(t, svc.executeFulfillment(ctx, order.ID)) + audits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("FULFILLMENT_FAILED")). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, audits) + + err = svc.RetryFulfillment(ctx, order.ID) + require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err)) +} diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index e49c54ec59..d4e313be8a 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -210,7 +210,8 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float if err != nil { return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found") } - ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed} + // REFUND_PENDING 只能通过 QueryAndFinalizeRefund 收敛:重新发起会重复扣减并向网关重复退款。 + ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed} if !psSliceContains(ok, o.Status) { return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund") } @@ -295,8 +296,16 @@ func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int6 return repo.DeductAvailableBalance(ctx, userID, amount) } +// invalidateRefundBalanceCache 退款扣减或归还余额后失效计费缓存,否则网关在缓存 TTL 内仍按旧余额放行。 +func (s *PaymentService) invalidateRefundBalanceCache(ctx context.Context, userID int64) { + if s.redeemService == nil { + return + } + s.redeemService.invalidateRedeemCaches(ctx, userID, &RedeemCode{Type: RedeemTypeBalance}) +} + func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) { - c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx) + c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx) if err != nil { return nil, fmt.Errorf("lock: %w", err) } @@ -313,6 +322,7 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref return nil, fmt.Errorf("deduction: %w", err) } p.BalanceToDeduct = deducted + s.invalidateRefundBalanceCache(ctx, p.Order.UserID) } else { slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID) p.BalanceToDeduct = 0 @@ -436,31 +446,37 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually") } - pendingDetail := s.latestRefundPendingDetail(ctx, oid) + pendingDetail, err := s.latestRefundPendingDetail(ctx, oid) + if err != nil { + return nil, err + } finishProviderCall := servertiming.ObserveDependency(ctx, "payment") resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{ - TradeNo: o.PaymentTradeNo, - OrderID: o.OutTradeNo, - RefundID: pendingDetail.RefundID, - Amount: formatGatewayRefundAmount(o.RefundAmount, o), + TradeNo: o.PaymentTradeNo, + OrderID: o.OutTradeNo, + RefundID: pendingDetail.RefundID, + Amount: formatGatewayRefundAmount(o.RefundAmount, o), + RequestedAt: pendingDetail.requestedAt, }) finishProviderCall() if err != nil { return nil, fmt.Errorf("query refund: %w", err) } - if err := validateRefundProviderResponse(resp); err != nil { - return s.finalizeRefundFailed(ctx, o, err) - } - plan := s.refundFinalizePlan(o) - if !pendingDetail.DeductionRollbackOK { + switch { + case pendingDetail.DeductionHeld: + pendingDetail.applyTo(plan) + case !pendingDetail.DeductionRollbackOK: plan.BalanceToDeduct = 0 plan.SubDaysToDeduct = 0 - } else if o.OrderType == payment.OrderTypeSubscription { + case o.OrderType == payment.OrderTypeSubscription: if early := s.prepDeduct(ctx, o, plan, true); early != nil { return early, nil } } + if err := validateRefundProviderResponse(resp); err != nil { + return s.finalizeRefundFailed(ctx, plan, err) + } switch strings.TrimSpace(resp.Status) { case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded: return s.finalizePendingRefundSuccess(ctx, plan) @@ -468,7 +484,7 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) s.writeAuditLog(ctx, oid, "REFUND_QUERY_PENDING", "admin", map[string]any{"refundID": resp.RefundID}) return &RefundResult{Success: false, Warning: "gateway refund is still pending confirmation"}, nil default: - return s.finalizeRefundFailed(ctx, o, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status))) + return s.finalizeRefundFailed(ctx, plan, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status))) } } @@ -505,6 +521,9 @@ func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *Re if err = tx.Commit(); err != nil { return nil, fmt.Errorf("commit refund finalization: %w", err) } + if !p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { + s.invalidateRefundBalanceCache(ctx, p.Order.UserID) + } return result, nil } @@ -533,6 +552,10 @@ func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan { } func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error { + if p.deductionHeld { + // 进入 pending 时扣减已保留,终态成功无需再扣。 + return nil + } if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct) if err != nil { @@ -554,30 +577,164 @@ func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *Refun return nil } -func (s *PaymentService) finalizeRefundFailed(ctx context.Context, o *dbent.PaymentOrder, gErr error) (*RefundResult, error) { - now := time.Now() - _, _ = s.entClient.PaymentOrder.UpdateOneID(o.ID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx) - s.writeAuditLog(ctx, o.ID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)}) - return &RefundResult{Success: false, Warning: "gateway refund failed: " + psErrMsg(gErr)}, nil +// finalizeRefundFailed 将 REFUND_PENDING 收敛为 REFUND_FAILED,并归还进入 pending 时保留的扣减。 +// 认领状态、归还扣减与审计在同一事务内完成:两者分开提交时,进程在中间退出会留下 +// 已失败但扣减未归还、且没有 REFUND_ROLLBACK_FAILED 标记的订单,重试退款会再扣一次。 +func (s *PaymentService) finalizeRefundFailed(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) { + warning := "gateway refund failed: " + psErrMsg(gErr) + returnErr, err := s.finalizeRefundFailedTx(ctx, p, gErr, nil) + if err != nil { + return nil, err + } + if returnErr == nil { + if p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { + s.invalidateRefundBalanceCache(ctx, p.Order.UserID) + } + return &RefundResult{Success: false, Warning: warning}, nil + } + + // 归还失败(整个事务已回滚,订单仍为 REFUND_PENDING):仍收敛为 REFUND_FAILED, + // 并同事务写入 REFUND_ROLLBACK_FAILED,使重试退款跳过扣减,避免二次扣减。 + slog.Error("[CRITICAL] refund failed but held deduction could not be returned", "orderID", p.OrderID, "error", returnErr) + if _, err := s.finalizeRefundFailedTx(ctx, p, gErr, returnErr); err != nil { + return nil, err + } + return &RefundResult{Success: false, Warning: warning + "; refund deduction rollback failed"}, nil } +// finalizeRefundFailedTx 在单个事务内认领 REFUND_PENDING → REFUND_FAILED。rollbackErr 为 nil 时同事务 +// 归还保留的扣减,归还失败则回滚整个事务并通过 returnErr 返回;rollbackErr 非 nil 时不再归还, +// 改为同事务记录 REFUND_ROLLBACK_FAILED。 +func (s *PaymentService) finalizeRefundFailedTx(ctx context.Context, p *RefundPlan, gErr, rollbackErr error) (returnErr error, err error) { + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin refund failure finalization: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + txCtx := dbent.NewTxContext(ctx, tx) + + claimed, err := tx.PaymentOrder.Update(). + Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)). + SetStatus(OrderStatusRefundFailed). + SetFailedAt(time.Now()). + SetFailedReason(psErrMsg(gErr)). + Save(txCtx) + if err != nil { + return nil, fmt.Errorf("mark refund failed: %w", err) + } + if claimed == 0 { + return nil, infraerrors.Conflict("CONFLICT", "order status changed") + } + + if p.deductionHeld && rollbackErr == nil { + if err := s.returnHeldRefundDeduction(txCtx, p); err != nil { + return err, nil + } + } + if rollbackErr != nil { + if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_ROLLBACK_FAILED", map[string]any{ + "gatewayError": psErrMsg(gErr), + "rollbackError": psErrMsg(rollbackErr), + "balanceDeducted": p.BalanceToDeduct, + "subDaysDeducted": p.SubDaysToDeduct, + }); err != nil { + return nil, err + } + } + if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_FAILED", map[string]any{"detail": psErrMsg(gErr)}); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit refund failure finalization: %w", err) + } + committed = true + return nil, nil +} + +// returnHeldRefundDeduction 归还进入 REFUND_PENDING 时保留的扣减;ctx 携带事务时随事务提交或回滚。 +func (s *PaymentService) returnHeldRefundDeduction(ctx context.Context, p *RefundPlan) error { + if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { + if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { + return fmt.Errorf("return held balance deduction: %w", err) + } + } + if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 { + if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil { + return fmt.Errorf("return held subscription deduction: %w", err) + } + } + return nil +} + +func createPaymentAuditLogTx(ctx context.Context, client *dbent.Client, oid int64, action string, detail map[string]any) error { + raw, err := json.Marshal(detail) + if err != nil { + return fmt.Errorf("marshal %s audit: %w", action, err) + } + if _, err := client.PaymentAuditLog.Create(). + SetOrderID(strconv.FormatInt(oid, 10)). + SetAction(action). + SetDetail(string(raw)). + SetOperator("admin"). + Save(ctx); err != nil { + return fmt.Errorf("write %s audit: %w", action, err) + } + return nil +} + +// refundPendingAuditDetail 记录在 REFUND_PENDING 审计日志中。DeductionHeld 为 true 表示 +// 进入 pending 时扣减被保留;旧记录没有该字段,扣减已在 pending 时归还(DeductionRollbackOK)。 type refundPendingAuditDetail struct { - RefundID string `json:"refundID"` - DeductionRollbackOK bool `json:"deductionRollbackOK"` + RefundID string `json:"refundID"` + RefundAmount float64 `json:"refundAmount"` + Reason string `json:"reason"` + Force bool `json:"force"` + DeductionRollbackOK bool `json:"deductionRollbackOK,omitempty"` + DeductionHeld bool `json:"deductionHeld,omitempty"` + DeductionType string `json:"deductionType,omitempty"` + BalanceDeducted float64 `json:"balanceDeducted"` + SubDaysDeducted int `json:"subDaysDeducted"` + SubscriptionID int64 `json:"subscriptionID,omitempty"` + // requestedAt 取自审计记录的创建时间,不参与序列化。 + requestedAt time.Time } -func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail { +func (d refundPendingAuditDetail) applyTo(p *RefundPlan) { + p.deductionHeld = true + p.DeductionType = d.DeductionType + if p.DeductionType == "" { + p.DeductionType = payment.DeductionTypeNone + } + p.BalanceToDeduct = d.BalanceDeducted + p.SubDaysToDeduct = d.SubDaysDeducted + p.SubscriptionID = d.SubscriptionID +} + +// latestRefundPendingDetail 读取最近一次 REFUND_PENDING 审计。没有记录视为旧订单(扣减已在 +// pending 时归还);读取或解析失败必须返回错误:误判为旧记录会让保留的扣减被重复扣减或不再归还。 +func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) (refundPendingAuditDetail, error) { + detail := refundPendingAuditDetail{DeductionRollbackOK: true} logEntry, err := s.entClient.PaymentAuditLog.Query(). Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")). - Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())). + Order(paymentauditlog.ByCreatedAt(sql.OrderDesc()), paymentauditlog.ByID(sql.OrderDesc())). First(ctx) - if err != nil || logEntry == nil { - return refundPendingAuditDetail{DeductionRollbackOK: true} + if err != nil { + if dbent.IsNotFound(err) { + return detail, nil + } + return refundPendingAuditDetail{}, fmt.Errorf("load refund pending audit: %w", err) + } + if err := json.Unmarshal([]byte(logEntry.Detail), &detail); err != nil { + return refundPendingAuditDetail{}, fmt.Errorf("parse refund pending audit: %w", err) } - detail := refundPendingAuditDetail{DeductionRollbackOK: true} - _ = json.Unmarshal([]byte(logEntry.Detail), &detail) detail.RefundID = strings.TrimSpace(detail.RefundID) - return detail + detail.requestedAt = logEntry.CreatedAt + return detail, nil } // getRefundProvider creates a provider using the order's original instance config. @@ -644,16 +801,36 @@ func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Clien return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil } -func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) { - balanceDeducted := p.BalanceToDeduct - subDaysDeducted := p.SubDaysToDeduct - rollbackOK := s.RollbackRefund(ctx, p, nil) - if rollbackOK { - p.BalanceToDeduct = 0 - p.SubDaysToDeduct = 0 +// markRefundPending 在网关未给出终态时保留已执行的扣减:若此时归还,用户可在 pending 期间 +// 消费这部分余额,终态成功时只能扣到剩余余额。网关确认失败后由 finalizeRefundFailed 归还。 +// 订单状态与审计记录同事务写入,确保终态收敛时能读到保留的扣减。 +func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (_ *RefundResult, err error) { + detail, err := json.Marshal(refundPendingAuditDetail{ + RefundID: refundResponseID(resp), + RefundAmount: p.RefundAmount, + Reason: p.Reason, + Force: p.Force, + DeductionHeld: true, + DeductionType: p.DeductionType, + BalanceDeducted: p.BalanceToDeduct, + SubDaysDeducted: p.SubDaysToDeduct, + SubscriptionID: p.SubscriptionID, + }) + if err != nil { + return nil, fmt.Errorf("marshal refund pending audit: %w", err) } - _, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID). + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin mark refund pending: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + slog.Error("[CRITICAL] gateway refund pending but order not marked", "orderID", p.OrderID, "error", err) + } + }() + _, err = tx.PaymentOrder.UpdateOneID(p.OrderID). SetStatus(OrderStatusRefundPending). SetRefundAmount(p.RefundAmount). SetRefundReason(p.Reason). @@ -665,25 +842,18 @@ func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, r if err != nil { return nil, fmt.Errorf("mark refund pending: %w", err) } - - detail := map[string]any{ - "refundID": refundResponseID(resp), - "refundAmount": p.RefundAmount, - "reason": p.Reason, - "force": p.Force, - "balanceDeducted": p.BalanceToDeduct, - "subDaysDeducted": p.SubDaysToDeduct, - "balanceRolledBack": balanceDeducted, - "subDaysRolledBack": subDaysDeducted, - "deductionRollbackOK": rollbackOK, + if _, err = tx.PaymentAuditLog.Create(). + SetOrderID(strconv.FormatInt(p.OrderID, 10)). + SetAction("REFUND_PENDING"). + SetDetail(string(detail)). + SetOperator("admin"). + Save(ctx); err != nil { + return nil, fmt.Errorf("write refund pending audit: %w", err) } - s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail) - - warning := "gateway refund is pending confirmation" - if !rollbackOK { - warning += "; refund deduction rollback failed" + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("commit mark refund pending: %w", err) } - return &RefundResult{Success: false, Warning: warning}, nil + return &RefundResult{Success: false, Warning: "gateway refund is pending confirmation"}, nil } func refundResponseID(resp *payment.RefundResponse) string { @@ -700,6 +870,7 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct}) return false } + s.invalidateRefundBalanceCache(ctx, p.Order.UserID) } if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 { if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil { diff --git a/backend/internal/service/payment_refund_pending_hold_test.go b/backend/internal/service/payment_refund_pending_hold_test.go new file mode 100644 index 0000000000..ae09957e81 --- /dev/null +++ b/backend/internal/service/payment_refund_pending_hold_test.go @@ -0,0 +1,312 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "math" + "strconv" + "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" + "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +type pendingRefundProviderTestDouble struct { + refundProviderTestDouble + queryStatus string + refundCalls int +} + +func (p *pendingRefundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) { + p.refundCalls++ + return &payment.RefundResponse{RefundID: "rf_hold", Status: payment.ProviderStatusPending}, nil +} + +func (p *pendingRefundProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) { + return &payment.RefundResponse{RefundID: "rf_hold", Status: p.queryStatus}, nil +} + +// refundHoldWallet 模拟用户余额:DeductAvailableBalance 只能扣到当前余额。 +type refundHoldWallet struct { + balance float64 + deductCalls int + restoreCalls int +} + +func (w *refundHoldWallet) repo(userID int64) *mockUserRepo { + return &mockUserRepo{ + getByIDUser: &User{ID: userID, Balance: w.balance}, + deductAvailableBalanceFn: func(_ context.Context, _ int64, amount float64) (float64, error) { + w.deductCalls++ + deducted := math.Min(amount, math.Max(w.balance, 0)) + w.balance -= deducted + return deducted, nil + }, + updateBalanceFn: func(_ context.Context, _ int64, amount float64) error { + w.restoreCalls++ + w.balance += amount + return nil + }, + } +} + +func TestRefundPendingKeepsBalanceDeductedUntilGatewaySettles(t *testing.T) { + for _, tc := range []struct { + name string + queryStatus string + wantStatus string + wantBalance float64 + wantRestore int + }{ + {name: "gateway succeeds", queryStatus: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantBalance: 0}, + {name: "gateway fails", queryStatus: payment.ProviderStatusFailed, wantStatus: OrderStatusRefundFailed, wantBalance: 100, wantRestore: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createCompletedRefundableOrderForTest(t, ctx, client, "hold-flow") + + wallet := &refundHoldWallet{balance: 100} + prov := &pendingRefundProviderTestDouble{queryStatus: tc.queryStatus} + restore := replacePaymentProviderFactoryForTest(t, prov) + defer restore() + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)} + + plan, early, err := svc.PrepareRefund(ctx, order.ID, 0, "user asked", false, true) + require.NoError(t, err) + require.Nil(t, early) + result, err := svc.ExecuteRefund(ctx, plan) + require.NoError(t, err) + require.False(t, result.Success) + require.Contains(t, result.Warning, "pending confirmation") + + // pending 期间余额必须保持扣减,否则用户可先消费再等网关退款成功。 + require.Zero(t, wallet.balance) + require.Zero(t, wallet.restoreCalls) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundPending, reloaded.Status) + + // pending 订单不能再次发起退款(会重复扣减并重复调用网关)。 + _, _, err = svc.PrepareRefund(ctx, order.ID, 0, "again", true, true) + require.Equal(t, "INVALID_STATUS", infraerrors.Reason(err)) + + result, err = svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, tc.queryStatus == payment.ProviderStatusSuccess, result.Success) + if result.Success { + require.Equal(t, 100.0, result.BalanceDeducted) + } + require.Equal(t, 1, wallet.deductCalls, "held deduction must not be applied twice") + require.Equal(t, tc.wantRestore, wallet.restoreCalls) + require.Equal(t, tc.wantBalance, wallet.balance) + require.Equal(t, 1, prov.refundCalls) + + reloaded, err = client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, tc.wantStatus, reloaded.Status) + + _, err = svc.QueryAndFinalizeRefund(ctx, order.ID) + require.Equal(t, "INVALID_STATUS", infraerrors.Reason(err)) + require.Equal(t, tc.wantRestore, wallet.restoreCalls) + }) + } +} + +func TestFinalizeRefundFailedRestoresHeldDeductionOnce(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-failed-once", + `{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`) + + wallet := &refundHoldWallet{} + svc := &PaymentService{entClient: client, userRepo: wallet.repo(order.UserID)} + plan := svc.refundFinalizePlan(order) + detail, err := svc.latestRefundPendingDetail(ctx, order.ID) + require.NoError(t, err) + detail.applyTo(plan) + + first, err := svc.finalizeRefundFailed(ctx, plan, context.DeadlineExceeded) + require.NoError(t, err) + require.False(t, first.Success) + second, err := svc.finalizeRefundFailed(ctx, plan, context.DeadlineExceeded) + require.Nil(t, second) + require.Equal(t, "CONFLICT", infraerrors.Reason(err)) + + require.Equal(t, 1, wallet.restoreCalls) + require.Equal(t, 60.0, wallet.balance) +} + +func TestQueryAndFinalizeRefundHeldDeductionWithoutBalanceDeduction(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + // 管理员选择不扣余额时,终态收敛也不能按旧逻辑补扣。 + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-no-deduct", + `{"refundID":"rf_test","deductionHeld":true,"deductionType":"none","balanceDeducted":0}`) + + wallet := &refundHoldWallet{balance: 100} + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusSuccess}) + defer restore() + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.True(t, result.Success) + require.Zero(t, wallet.deductCalls) + require.Equal(t, 100.0, wallet.balance) +} + +type successRefundProviderTestDouble struct { + refundProviderTestDouble + refundCalls int +} + +func (p *successRefundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) { + p.refundCalls++ + return &payment.RefundResponse{RefundID: "rf_retry", Status: payment.ProviderStatusSuccess}, nil +} + +func countRefundAuditsForTest(t *testing.T, ctx context.Context, client *dbent.Client, orderID int64, action string) int { + t.Helper() + n, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10)), paymentauditlog.ActionEQ(action)). + Count(ctx) + require.NoError(t, err) + return n +} + +func TestFinalizeRefundFailedRecordsRollbackFailureWhenHeldDeductionCannotBeReturned(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-return-fails", + `{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`) + + wallet := &refundHoldWallet{balance: 40} + repo := wallet.repo(order.UserID) + repo.updateBalanceFn = func(context.Context, int64, float64) error { + wallet.restoreCalls++ + return errors.New("db unavailable") + } + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusFailed}) + defer restore() + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: repo} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.False(t, result.Success) + require.Contains(t, result.Warning, "refund deduction rollback failed") + require.Equal(t, 1, wallet.restoreCalls) + require.Equal(t, 40.0, wallet.balance) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundFailed, reloaded.Status) + require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_ROLLBACK_FAILED")) + require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_FAILED")) + + // 扣减仍被保留:重试退款必须跳过扣减,否则同一笔退款会扣两次。 + retryProvider := &successRefundProviderTestDouble{} + restoreRetry := replacePaymentProviderFactoryForTest(t, retryProvider) + defer restoreRetry() + plan, early, err := svc.PrepareRefund(ctx, order.ID, 0, "retry", true, true) + require.NoError(t, err) + require.Nil(t, early) + retried, err := svc.ExecuteRefund(ctx, plan) + require.NoError(t, err) + require.True(t, retried.Success) + require.Zero(t, wallet.deductCalls) + require.Equal(t, 40.0, wallet.balance) + require.Equal(t, 1, retryProvider.refundCalls) +} + +func TestFinalizeRefundFailedReturnsHeldDeductionAtomically(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-return-atomic", + `{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`) + + wallet := &refundHoldWallet{} + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusFailed}) + defer restore() + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.False(t, result.Success) + require.NotContains(t, result.Warning, "rollback failed") + require.Equal(t, 1, wallet.restoreCalls) + require.Equal(t, 60.0, wallet.balance) + require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_FAILED")) + require.Zero(t, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_ROLLBACK_FAILED")) +} + +func TestQueryAndFinalizeRefundRejectsUnreadablePendingAudit(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + // 审计无法解析时不能退化为旧逻辑:成功会二次扣减,失败会吞掉保留的扣减。 + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-bad-audit", `not-json`) + + for _, queryStatus := range []string{payment.ProviderStatusSuccess, payment.ProviderStatusFailed} { + wallet := &refundHoldWallet{balance: 100} + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: queryStatus}) + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + restore() + require.Error(t, err, queryStatus) + require.Nil(t, result) + require.Zero(t, wallet.deductCalls) + require.Zero(t, wallet.restoreCalls) + require.Equal(t, 100.0, wallet.balance) + } + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundPending, reloaded.Status) +} + +func createCompletedRefundableOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder { + t.Helper() + user, err := client.User.Create(). + SetEmail(suffix + "@example.com"). + SetPasswordHash("hash"). + SetUsername(suffix). + Save(ctx) + require.NoError(t, err) + inst, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeStripe). + SetName(suffix + "-provider"). + SetConfig("{}"). + SetSupportedTypes("stripe"). + SetEnabled(true). + SetRefundEnabled(true). + Save(ctx) + require.NoError(t, err) + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(100). + SetPayAmount(100). + SetFeeRate(0). + SetRechargeCode("REFUND-" + suffix). + SetOutTradeNo("sub2_" + suffix). + SetPaymentType(payment.TypeStripe). + SetPaymentTradeNo("pi_" + suffix). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusCompleted). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderInstanceID(strconv.FormatInt(inst.ID, 10)). + Save(ctx) + require.NoError(t, err) + return order +} diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index 8a534e473e..834c7f4571 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -299,7 +299,7 @@ func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) { require.Error(t, validateRefundProviderResponse(nil)) } -func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) { +func TestFinishRefundPendingMarksOrderPendingAndHoldsDeduction(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -357,8 +357,8 @@ func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) require.NotNil(t, result) require.False(t, result.Success) require.Contains(t, result.Warning, "pending confirmation") - require.Equal(t, 40.0, rolledBack) - require.Zero(t, plan.BalanceToDeduct) + require.Zero(t, rolledBack, "deduction must stay held while the gateway refund is pending") + require.Equal(t, 40.0, plan.BalanceToDeduct) reloaded, err := client.PaymentOrder.Get(ctx, order.ID) require.NoError(t, err) @@ -373,6 +373,11 @@ func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) Count(ctx) require.NoError(t, err) require.Equal(t, 1, pendingAudits) + detail, err := svc.latestRefundPendingDetail(ctx, order.ID) + require.NoError(t, err) + require.True(t, detail.DeductionHeld) + require.Equal(t, payment.DeductionTypeBalance, detail.DeductionType) + require.Equal(t, 40.0, detail.BalanceDeducted) successAudits, err := client.PaymentAuditLog.Query(). Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). Count(ctx) @@ -502,6 +507,26 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) { } } +func TestQueryAndFinalizeRefundPassesPendingAuditTimeToProvider(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderForTest(t, ctx, client, "query-requested-at") + pendingAudit, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")). + Only(ctx) + require.NoError(t, err) + + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: &mockUserRepo{}} + prov := &refundQueryProviderTestDouble{refundResponse: &payment.RefundResponse{RefundID: "rf_test", Status: payment.ProviderStatusPending}} + restore := replacePaymentProviderFactoryForTest(t, prov) + defer restore() + + _, err = svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, "rf_test", prov.lastQuery.RefundID) + require.True(t, pendingAudit.CreatedAt.Equal(prov.lastQuery.RequestedAt), "RequestedAt = %v, want %v", prov.lastQuery.RequestedAt, pendingAudit.CreatedAt) +} + func TestFinalizePendingRefundSuccessRejectsStaleCallerBeforeSecondDeduction(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -586,6 +611,11 @@ func TestQueryAndFinalizeRefundUnsupportedProviderReturnsClearError(t *testing.T func createPendingRefundOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder { t.Helper() + return createPendingRefundOrderWithDetailForTest(t, ctx, client, suffix, `{"refundID":"rf_test","deductionRollbackOK":true}`) +} + +func createPendingRefundOrderWithDetailForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix, pendingDetail string) *dbent.PaymentOrder { + t.Helper() user, err := client.User.Create(). SetEmail(suffix + "@example.com"). @@ -631,7 +661,7 @@ func createPendingRefundOrderForTest(t *testing.T, ctx context.Context, client * SetOrderID(strconv.FormatInt(order.ID, 10)). SetAction("REFUND_PENDING"). SetOperator("admin"). - SetDetail(`{"refundID":"rf_test","deductionRollbackOK":true}`). + SetDetail(pendingDetail). Save(ctx) require.NoError(t, err) return order @@ -671,8 +701,10 @@ func (refundProviderTestDouble) Refund(context.Context, payment.RefundRequest) ( type refundQueryProviderTestDouble struct { refundProviderTestDouble refundResponse *payment.RefundResponse + lastQuery payment.RefundQueryRequest } -func (p *refundQueryProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) { +func (p *refundQueryProviderTestDouble) QueryRefund(_ context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) { + p.lastQuery = req return p.refundResponse, nil } diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go index 7bc76c8009..3928b64988 100644 --- a/backend/internal/service/payment_resume_service.go +++ b/backend/internal/service/payment_resume_service.go @@ -244,7 +244,16 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https") } + // The return URL is handed to payment gateways verbatim: keep it to a + // plain hostname or IP literal and the canonical path, without userinfo. + if parsed.User != nil { + return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must not contain user info") + } + if !isPlainReturnURLHost(parsed.Hostname()) { + return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url host is invalid") + } parsed.Fragment = "" + parsed.RawPath = "" // Client-supplied query parameters are dropped: buildPaymentReturnURL adds // its own signed set, and user-controlled pairs such as // "trade_status=TRADE_SUCCESS" inside the signed return_url value can be @@ -264,6 +273,25 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e return parsed.String(), nil } +// isPlainReturnURLHost reports whether host is an IP literal or a hostname +// made only of letters, digits, '.', '-' and '_'. +func isPlainReturnURLHost(host string) bool { + if host == "" { + return false + } + if net.ParseIP(host) != nil { + return true + } + for _, c := range host { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == '_': + default: + return false + } + } + return true +} + func allowedReturnURLHost(returnURLHost string, requestHost string, refererURL string) bool { if sameOriginHost(returnURLHost, requestHost) { return true diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 54b85b4a37..f4e66c8991 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -109,6 +109,42 @@ func TestCanonicalizeReturnURLStripsSmuggledTradeStatus(t *testing.T) { } } +func TestCanonicalizeReturnURLRejectsNonPlainHostAndUserinfo(t *testing.T) { + t.Parallel() + + badHost := "a&b=c.example.com" + cases := []struct { + name string + raw string + srcHost string + srcURL string + }{ + {"host matched via referer", "https://" + badHost + "/payment/result", "app.example.com", "https://" + badHost + "/purchase"}, + {"host matched via request host", "https://" + badHost + "/payment/result", badHost, ""}, + {"userinfo", "https://a&b=c@app.example.com/payment/result", "app.example.com", ""}, + } + for _, tc := range cases { + if got, err := CanonicalizeReturnURL(tc.raw, tc.srcHost, tc.srcURL); err == nil { + t.Fatalf("%s: CanonicalizeReturnURL accepted %q", tc.name, got) + } + } +} + +func TestCanonicalizeReturnURLAllowsIPAndPortHosts(t *testing.T) { + t.Parallel() + + for _, raw := range []string{ + "http://127.0.0.1:3000/payment/result", + "http://[::1]:8080/payment/result", + "https://pay_site-1.example.com/payment/result", + } { + parsed, _ := url.Parse(raw) + if _, err := CanonicalizeReturnURL(raw, parsed.Host, ""); err != nil { + t.Fatalf("CanonicalizeReturnURL(%q) returned error: %v", raw, err) + } + } +} + func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index dae1a7d938..41276d6251 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -134,6 +134,8 @@ type RefundPlan struct { BalanceToDeduct float64 SubDaysToDeduct int SubscriptionID int64 + // deductionHeld 表示扣减已在进入 REFUND_PENDING 前执行并保留,终态收敛时不再重复扣减。 + deductionHeld bool } type RefundResult struct { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 6b8b049838..36e92361d4 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -217,6 +217,9 @@ security: # 网关配置 # ============================================================================= gateway: + # Minimum max_output_tokens when converting Chat Completions or Anthropic requests to Responses (minimum 16, default 128) + # Chat Completions 或 Anthropic 请求转换为 Responses 时的最小 max_output_tokens(最小 16,默认 128) + min_output_tokens: 128 # Timeout for waiting upstream response headers (seconds) # 等待上游响应头超时时间(秒) response_header_timeout: 600 diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index ea3d01e282..ce7009c66c 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -1,49 +1,27 @@