mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge remote-tracking branch 'origin/main' into pr-8114
# Conflicts: # backend/internal/service/gemini_chat_completions_compat_service.go
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: `<html>502 Bad Gateway</html>`,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 尚未
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -134,6 +134,8 @@ type RefundPlan struct {
|
||||
BalanceToDeduct float64
|
||||
SubDaysToDeduct int
|
||||
SubscriptionID int64
|
||||
// deductionHeld 表示扣减已在进入 REFUND_PENDING 前执行并保留,终态收敛时不再重复扣减。
|
||||
deductionHeld bool
|
||||
}
|
||||
|
||||
type RefundResult struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
<template>
|
||||
<div class="min-w-0 w-full space-y-2">
|
||||
<div class="grid grid-cols-2 items-center gap-2 sm:flex sm:flex-wrap">
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="t('admin.accounts.searchAccounts')"
|
||||
class="col-span-2 min-w-0 w-full sm:w-56"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
@search="$emit('change')"
|
||||
/>
|
||||
<Select :model-value="filters.platform" :aria-label="t('admin.accounts.allPlatforms')" class="min-w-0 w-full sm:w-36" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
|
||||
<Select :model-value="filters.status" :aria-label="t('admin.accounts.allStatus')" class="min-w-0 w-full sm:w-36" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary col-span-2 min-w-0 w-full sm:w-auto"
|
||||
:aria-expanded="moreFiltersOpen"
|
||||
:aria-controls="moreFiltersId"
|
||||
:aria-label="activeSecondaryCount ? t('admin.accounts.moreFiltersActive', { count: activeSecondaryCount }) : t('admin.accounts.moreFilters')"
|
||||
@click="moreFiltersOpen = !moreFiltersOpen"
|
||||
>
|
||||
{{ t('admin.accounts.moreFilters') }}
|
||||
<span v-if="activeSecondaryCount" aria-hidden="true" class="rounded-full bg-primary-100 px-2 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">{{ activeSecondaryCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="moreFiltersOpen" :id="moreFiltersId" class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Select :model-value="filters.type" :aria-label="t('admin.accounts.allTypes')" class="min-w-0 w-full" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
||||
<Select :model-value="filters.privacy_mode" :aria-label="t('admin.accounts.allPrivacyModes')" class="min-w-0 w-full" :options="privacyOpts" @update:model-value="updatePrivacyMode" @change="$emit('change')" />
|
||||
<Select :model-value="filters.group" :aria-label="t('admin.accounts.allGroups')" class="min-w-0 w-full" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
|
||||
</div>
|
||||
<!-- 窄屏两列网格铺满宽度,sm 起恢复单行流式排布 -->
|
||||
<div class="grid w-full grid-cols-2 gap-3 sm:flex sm:w-auto sm:flex-wrap sm:items-center">
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="t('admin.accounts.searchAccounts')"
|
||||
class="col-span-2 sm:w-52"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
@search="$emit('change')"
|
||||
/>
|
||||
<Select :model-value="filters.platform" class="min-w-0 sm:w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
|
||||
<Select :model-value="filters.type" class="min-w-0 sm:w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
||||
<Select :model-value="filters.status" class="min-w-0 sm:w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
||||
<Select :model-value="filters.privacy_mode" class="min-w-0 sm:w-48" :options="privacyOpts" @update:model-value="updatePrivacyMode" @change="$emit('change')" />
|
||||
<Select :model-value="filters.group" class="col-span-2 min-w-0 sm:w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
|
||||
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
|
||||
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
||||
const moreFiltersOpen = ref(false)
|
||||
const moreFiltersId = useId()
|
||||
const activeSecondaryCount = computed(() =>
|
||||
['type', 'privacy_mode', 'group'].filter(key => {
|
||||
const value = props.filters[key]
|
||||
return value !== '' && value !== null && value !== undefined
|
||||
}).length
|
||||
)
|
||||
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
|
||||
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import AccountTableFilters from '../AccountTableFilters.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import SearchInput from '@/components/common/SearchInput.vue'
|
||||
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
|
||||
import type { AdminGroup } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: { count: number }) => params ? `${key} (${params.count})` : key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const defaultFilters = () => ({
|
||||
platform: '', type: '', status: '', privacy_mode: '', group: '',
|
||||
search: 'existing search', lite: '1', sort_by: 'priority', sort_order: 'asc'
|
||||
})
|
||||
|
||||
function mountFilters(filters: Record<string, unknown> = defaultFilters()) {
|
||||
return mount(AccountTableFilters, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
searchQuery: 'existing search', filters,
|
||||
groups: [{ id: 42, name: 'A very long group name' } as AdminGroup]
|
||||
},
|
||||
global: { stubs: { Teleport: true } }
|
||||
})
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('AccountTableFilters', () => {
|
||||
it('toggles an accessible secondary panel without emitting changes or clearing filters', async () => {
|
||||
const filters = { ...defaultFilters(), type: 'bedrock', privacy_mode: '__unset__', group: 'ungrouped' }
|
||||
const wrapper = mountFilters(filters)
|
||||
const toggle = wrapper.get('button[aria-controls]')
|
||||
const panel = wrapper.get(`[id="${toggle.attributes('aria-controls')}"]`)
|
||||
|
||||
expect(toggle.attributes('type')).toBe('button')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
expect(panel.isVisible()).toBe(false)
|
||||
expect(wrapper.findAllComponents(Select).filter(select => select.isVisible())).toHaveLength(2)
|
||||
await toggle.trigger('click')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(panel.isVisible()).toBe(true)
|
||||
expect(wrapper.findAllComponents(Select).every(select => select.isVisible())).toBe(true)
|
||||
await toggle.trigger('click')
|
||||
expect(panel.isVisible()).toBe(false)
|
||||
await toggle.trigger('click')
|
||||
expect(wrapper.props('filters')).toEqual(filters)
|
||||
expect(wrapper.emitted('update:filters')).toBeUndefined()
|
||||
expect(wrapper.emitted('change')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('indicates active secondary filters while collapsed and reacts to external resets', async () => {
|
||||
const wrapper = mountFilters({ ...defaultFilters(), platform: 'openai', status: 'active' })
|
||||
const toggle = wrapper.get('button[aria-controls]')
|
||||
expect(toggle.find('span').exists()).toBe(false)
|
||||
for (const [filters, count] of [
|
||||
[{ type: 'oauth', privacy_mode: '', group: '' }, 1],
|
||||
[{ type: 'setup-token', privacy_mode: '__unset__', group: 'ungrouped' }, 3],
|
||||
[{ type: '', privacy_mode: 'training_off', group: '42' }, 2]
|
||||
] as const) {
|
||||
await wrapper.setProps({ filters: { ...defaultFilters(), ...filters } })
|
||||
expect(toggle.get('span').text()).toBe(String(count))
|
||||
expect(toggle.attributes('aria-label')).toBe(`admin.accounts.moreFiltersActive (${count})`)
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
}
|
||||
await wrapper.setProps({ filters: { type: null, privacy_mode: undefined, group: '' } })
|
||||
expect(toggle.find('span').exists()).toBe(false)
|
||||
expect(toggle.attributes('aria-label')).toBe('admin.accounts.moreFilters')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves every option and emits a merged filter snapshot plus change for every selection', async () => {
|
||||
const filters = { ...defaultFilters(), platform: 'openai', type: 'oauth', status: 'active', privacy_mode: '__unset__', group: '42' }
|
||||
const wrapper = mountFilters(filters)
|
||||
await wrapper.get('button[aria-controls]').trigger('click')
|
||||
const expectedOptions = [
|
||||
['platform', ['', ...CONCRETE_PLATFORM_OPTIONS.map(option => option.value)]],
|
||||
['status', ['', 'active', 'inactive', 'error', 'rate_limited', 'temp_unschedulable', 'unschedulable']],
|
||||
['type', ['', 'oauth', 'setup-token', 'apikey', 'bedrock']],
|
||||
['privacy_mode', ['', '__unset__', 'training_off', 'training_set_cf_blocked', 'training_set_failed']],
|
||||
['group', ['', 'ungrouped', '42']]
|
||||
] as const
|
||||
let changes = 0
|
||||
for (const [index, [key, values]] of expectedOptions.entries()) {
|
||||
const select = wrapper.findAllComponents(Select)[index]
|
||||
expect(select.props('options').map(option => option.value)).toEqual(values)
|
||||
expect(select.props('modelValue')).toBe(filters[key])
|
||||
expect(select.get('button').attributes('aria-label')).not.toBe('Select option')
|
||||
for (const [optionIndex, value] of values.entries()) {
|
||||
await select.get('button').trigger('click')
|
||||
await select.findAll('[role="option"]')[optionIndex].trigger('click')
|
||||
changes += 1
|
||||
expect(wrapper.emitted('update:filters')?.at(-1)).toEqual([{ ...filters, [key]: value }])
|
||||
expect(wrapper.emitted('change')).toHaveLength(changes)
|
||||
expect(wrapper.props('filters')).toEqual(filters)
|
||||
}
|
||||
}
|
||||
expect(wrapper.findAllComponents(Select)[4].props('options')[2].label).toBe('A very long group name')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves immediate search updates and the debounced search change event', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountFilters()
|
||||
const search = wrapper.getComponent(SearchInput)
|
||||
expect(search.props('modelValue')).toBe('existing search')
|
||||
expect(search.props('placeholder')).toBe('admin.accounts.searchAccounts')
|
||||
await search.get('input').setValue('new search')
|
||||
expect(wrapper.emitted('update:searchQuery')).toEqual([['new search']])
|
||||
expect(wrapper.emitted('change')).toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(wrapper.emitted('change')).toEqual([[]])
|
||||
expect(wrapper.emitted('update:filters')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses shrinkable, wrapping controls and unique panel IDs for multiple instances', () => {
|
||||
const wrapper = mountFilters()
|
||||
expect(wrapper.classes()).toContain('min-w-0')
|
||||
expect(wrapper.get('.grid').classes()).toEqual(expect.arrayContaining(['grid-cols-2', 'sm:flex-wrap']))
|
||||
for (const select of wrapper.findAllComponents(Select)) {
|
||||
expect(select.classes()).toEqual(expect.arrayContaining(['min-w-0', 'w-full']))
|
||||
expect(select.classes()).not.toContain('w-40')
|
||||
}
|
||||
const pair = mount(defineComponent({
|
||||
components: { AccountTableFilters },
|
||||
setup: () => ({ filters: defaultFilters() }),
|
||||
template: '<div><AccountTableFilters search-query="" :filters="filters" /><AccountTableFilters search-query="" :filters="filters" /></div>'
|
||||
}))
|
||||
const toggles = pair.findAll('button[aria-controls]')
|
||||
expect(toggles[0].attributes('aria-controls')).not.toBe(toggles[1].attributes('aria-controls'))
|
||||
wrapper.unmount()
|
||||
pair.unmount()
|
||||
})
|
||||
})
|
||||
@@ -79,8 +79,6 @@ export default {
|
||||
editAccount: 'Edit Account',
|
||||
deleteAccount: 'Delete Account',
|
||||
searchAccounts: 'Search accounts...',
|
||||
moreFilters: 'More filters',
|
||||
moreFiltersActive: 'More filters ({count} active)',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder: 'Enter notes',
|
||||
notesHint: 'Notes are optional',
|
||||
|
||||
@@ -78,8 +78,6 @@ export default {
|
||||
editAccount: '编辑账号',
|
||||
deleteAccount: '删除账号',
|
||||
searchAccounts: '搜索账号...',
|
||||
moreFilters: '更多筛选',
|
||||
moreFiltersActive: '更多筛选(已启用 {count} 项)',
|
||||
notes: '备注',
|
||||
notesPlaceholder: '请输入备注',
|
||||
notesHint: '备注可选',
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap-reverse items-start justify-between gap-3">
|
||||
<AccountTableFilters
|
||||
class="lg:w-auto lg:flex-1"
|
||||
v-model:searchQuery="params.search"
|
||||
:filters="params"
|
||||
:groups="groups"
|
||||
|
||||
@@ -874,7 +874,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
path: '/payment/stripe',
|
||||
query: {
|
||||
order_id: String(result.order_id),
|
||||
client_secret: result.client_secret,
|
||||
method: stripeMethod || undefined,
|
||||
resume_token: result.resume_token || undefined,
|
||||
},
|
||||
@@ -1082,7 +1081,6 @@ async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackCo
|
||||
path: '/payment/stripe',
|
||||
query: {
|
||||
order_id: String(result.order_id),
|
||||
client_secret: result.client_secret,
|
||||
method: stripeMethod,
|
||||
resume_token: result.resume_token || undefined,
|
||||
},
|
||||
|
||||
@@ -136,9 +136,14 @@ let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orderId = Number(route.query.order_id)
|
||||
const clientSecret = String(route.query.client_secret || '')
|
||||
const method = String(route.query.method || '')
|
||||
const resumeToken = typeof route.query.resume_token === 'string' ? route.query.resume_token : undefined
|
||||
// client_secret 只从本浏览器下单时写入的恢复快照读取,且快照订单必须与 URL 订单一致:
|
||||
// 不信任 URL 中的 client_secret,避免页面展示的订单与实际收款的 PaymentIntent 被拼接错绑。
|
||||
const restored = typeof window !== 'undefined'
|
||||
? readPaymentRecoverySnapshot(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), { resumeToken })
|
||||
: null
|
||||
const clientSecret = orderId && restored?.orderId === orderId ? restored.clientSecret : ''
|
||||
|
||||
if (!orderId || !clientSecret) {
|
||||
loading.value = false
|
||||
@@ -147,15 +152,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const restored = readPaymentRecoverySnapshot(
|
||||
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
|
||||
{ resumeToken },
|
||||
)
|
||||
if (restored?.orderId === orderId) {
|
||||
currency.value = normalizePaymentCurrency(restored.currency)
|
||||
}
|
||||
}
|
||||
currency.value = normalizePaymentCurrency(restored?.currency)
|
||||
const res = await paymentAPI.getOrder(orderId)
|
||||
order.value = res.data
|
||||
if (res.data.currency) {
|
||||
|
||||
@@ -65,6 +65,10 @@ vi.mock('@stripe/stripe-js/pure', () => ({
|
||||
|
||||
import StripePaymentView from '../StripePaymentView.vue'
|
||||
import { formatPaymentAmount } from '@/components/payment/currency'
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
type PaymentRecoverySnapshot,
|
||||
} from '@/components/payment/paymentFlow'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
|
||||
function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
|
||||
@@ -86,6 +90,33 @@ function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
|
||||
}
|
||||
}
|
||||
|
||||
function stripeSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}): PaymentRecoverySnapshot {
|
||||
return {
|
||||
orderId: 42,
|
||||
amount: 100,
|
||||
qrCode: '',
|
||||
expiresAt: '2099-01-01T00:30:00.000Z',
|
||||
paymentType: 'stripe',
|
||||
payUrl: '/payment/stripe?order_id=42',
|
||||
outTradeNo: 'sub2_stripe_42',
|
||||
clientSecret: 'pi_secret_42',
|
||||
intentId: '',
|
||||
currency: 'CNY',
|
||||
countryCode: '',
|
||||
paymentEnv: '',
|
||||
payAmount: 103,
|
||||
orderType: 'balance',
|
||||
paymentMode: '',
|
||||
resumeToken: '',
|
||||
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}) {
|
||||
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(stripeSnapshot(overrides)))
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return shallowMount(StripePaymentView, {
|
||||
global: {
|
||||
@@ -101,7 +132,6 @@ describe('StripePaymentView', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = {
|
||||
order_id: '42',
|
||||
client_secret: 'pi_secret_42',
|
||||
}
|
||||
routerPush.mockReset()
|
||||
getOrder.mockReset().mockResolvedValue({ data: orderFactory() })
|
||||
@@ -119,6 +149,42 @@ describe('StripePaymentView', () => {
|
||||
stripeInstance.confirmAlipayPayment.mockReset()
|
||||
stripeInstance.confirmWechatPayPayment.mockReset()
|
||||
window.localStorage.clear()
|
||||
writeSnapshot()
|
||||
})
|
||||
|
||||
it('uses the client secret from the local snapshot of the same order', async () => {
|
||||
routeState.query.method = 'alipay'
|
||||
mountView()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrder).toHaveBeenCalledWith(42)
|
||||
expect(stripeInstance.confirmAlipayPayment).toHaveBeenCalledWith('pi_secret_42', expect.any(Object))
|
||||
})
|
||||
|
||||
it('ignores a client secret supplied in the URL query', async () => {
|
||||
window.localStorage.clear()
|
||||
routeState.query = { order_id: '42', client_secret: 'pi_other_secret', method: 'alipay' }
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrder).not.toHaveBeenCalled()
|
||||
expect(loadStripe).not.toHaveBeenCalled()
|
||||
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('payment.stripeMissingParams')
|
||||
})
|
||||
|
||||
it('rejects a snapshot that belongs to a different order', async () => {
|
||||
writeSnapshot({ orderId: 7, clientSecret: 'pi_secret_7' })
|
||||
routeState.query = { order_id: '42', client_secret: 'pi_secret_42', method: 'alipay' }
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrder).not.toHaveBeenCalled()
|
||||
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('payment.stripeMissingParams')
|
||||
})
|
||||
|
||||
it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => {
|
||||
@@ -178,7 +244,8 @@ describe('StripePaymentView', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => {
|
||||
it('本地恢复快照未记录币种时使用订单接口返回的 Stripe 币种展示金额', async () => {
|
||||
writeSnapshot({ currency: '' })
|
||||
getOrder.mockResolvedValue({
|
||||
data: orderFactory({ currency: 'HKD', pay_amount: 103 }),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user