Merge remote-tracking branch 'origin/main' into pr-8114

# Conflicts:
#	backend/internal/service/gemini_chat_completions_compat_service.go
This commit is contained in:
shaw
2026-10-16 10:09:46 +08:00
57 changed files with 2111 additions and 383 deletions
+7
View File
@@ -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)
+23
View File
@@ -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)
}
+64 -9
View File
@@ -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")
}
}
+145 -47
View File
@@ -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)
}
+16 -1
View File
@@ -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
}
+6 -3
View File
@@ -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)
}
+9 -4
View File
@@ -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))
}
+224 -53
View File
@@ -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 {
+3
View File
@@ -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"
-2
View File
@@ -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,
},
+7 -10
View File
@@ -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 }),
})