diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 6f76ef4f..d7e15377 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,6 +17,7 @@ jobs: go-version-file: backend/go.mod check-latest: false cache: true + cache-dependency-path: backend/go.sum - name: Verify Go version run: | go version | grep -q 'go1.26.2' @@ -36,6 +37,7 @@ jobs: go-version-file: backend/go.mod check-latest: false cache: true + cache-dependency-path: backend/go.sum - name: Verify Go version run: | go version | grep -q 'go1.26.2' diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 47f8f518..64709b5b 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -36,19 +36,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { // Business layer ProviderSets repository.ProviderSet, service.ProviderSet, + payment.ProviderSet, middleware.ProviderSet, handler.ProviderSet, // Server layer ProviderSet server.ProviderSet, - // Payment providers - payment.ProvideRegistry, - payment.ProvideEncryptionKey, - payment.ProvideDefaultLoadBalancer, - service.ProvidePaymentConfigService, - service.ProvidePaymentOrderExpiryService, - // Privacy client factory for OpenAI training opt-out providePrivacyClientFactory, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bc4e5e46..dd9a4e58 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,7 +28,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index fe181a2f..cf58316c 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -233,12 +233,13 @@ func TestLoadForcedCodexInstructionsTemplate(t *testing.T) { configPath := filepath.Join(tempDir, "config.yaml") require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644)) - require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+templatePath+"\"\n"), 0o644)) + yamlSafePath := filepath.ToSlash(templatePath) + require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+yamlSafePath+"\"\n"), 0o644)) t.Setenv("DATA_DIR", tempDir) cfg, err := Load() require.NoError(t, err) - require.Equal(t, templatePath, cfg.Gateway.ForcedCodexInstructionsTemplateFile) + require.Equal(t, yamlSafePath, cfg.Gateway.ForcedCodexInstructionsTemplateFile) require.Equal(t, "server-prefix\n\n{{ .ExistingInstructions }}", cfg.Gateway.ForcedCodexInstructionsTemplate) } diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 9e985a79..9883d007 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1412,6 +1412,12 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { c.JSON(409, gin.H{ "error": "mixed_channel_warning", "message": mixedErr.Error(), + "details": gin.H{ + "group_id": mixedErr.GroupID, + "group_name": mixedErr.GroupName, + "current_platform": mixedErr.CurrentPlatform, + "other_platform": mixedErr.OtherPlatform, + }, }) return } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index a7a93d07..d2ccb8d6 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -628,6 +628,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { ModelMappingChain: l.ModelMappingChain, BillingTier: l.BillingTier, AccountRateMultiplier: l.AccountRateMultiplier, + AccountStatsCost: l.AccountStatsCost, IPAddress: l.IPAddress, Account: AccountSummaryFromService(l.Account), } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 1aab1dbb..8c1e166f 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -427,6 +427,8 @@ type AdminUsageLog struct { // AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理) AccountRateMultiplier *float64 `json:"account_rate_multiplier"` + // AccountStatsCost 自定义定价规则计算的账号统计费用(nil 表示使用默认公式) + AccountStatsCost *float64 `json:"account_stats_cost,omitempty"` // IPAddress 用户请求 IP(仅管理员可见) IPAddress *string `json:"ip_address,omitempty"` diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index bf404118..8a83bfeb 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -4,6 +4,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "github.com/Wei-Shaw/sub2api/internal/payment" @@ -72,9 +73,13 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) rawBody = string(body) } - provider, err := h.registry.GetProviderByKey(providerKey) + // Extract out_trade_no to look up the order's specific provider instance. + // This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts). + outTradeNo := extractOutTradeNo(rawBody, providerKey) + + provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo) if err != nil { - slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err) + slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err) writeSuccessResponse(c, providerKey) return } @@ -111,19 +116,40 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) writeSuccessResponse(c, providerKey) } +// extractOutTradeNo parses the webhook body to find the out_trade_no. +// This allows looking up the correct provider instance before verification. +func extractOutTradeNo(rawBody, providerKey string) string { + switch providerKey { + case payment.TypeEasyPay: + values, err := url.ParseQuery(rawBody) + if err == nil { + return values.Get("out_trade_no") + } + } + // For other providers (Stripe, Alipay direct, WxPay direct), the registry + // typically has only one instance, so no instance lookup is needed. + return "" +} + // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook. type wxpaySuccessResponse struct { Code string `json:"code"` Message string `json:"message"` } +// WeChat Pay webhook success response constants. +const ( + wxpaySuccessCode = "SUCCESS" + wxpaySuccessMessage = "成功" +) + // writeSuccessResponse sends the provider-specific success response. // WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; // Stripe expects an empty 200; others accept plain text "success". func writeSuccessResponse(c *gin.Context, providerKey string) { switch providerKey { case payment.TypeWxpay: - c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"}) + c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage}) case payment.TypeStripe: c.String(http.StatusOK, "") default: diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index b48a38fe..e33a567d 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -27,6 +27,8 @@ const ( maxEasypayResponseSize = 1 << 20 // 1MB tradeStatusSuccess = "TRADE_SUCCESS" signTypeMD5 = "MD5" + paymentModePopup = "popup" + deviceMobile = "mobile" ) // EasyPay implements payment.Provider for the EasyPay aggregation platform. @@ -61,7 +63,7 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe // Payment mode determined by instance config, not payment type. // "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php). mode := e.config["paymentMode"] - if mode == "popup" { + if mode == paymentModePopup { return e.createRedirectPayment(req) } return e.createAPIPayment(ctx, req) @@ -81,6 +83,9 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym if cid := e.resolveCID(req.PaymentType); cid != "" { params["cid"] = cid } + if req.IsMobile { + params["device"] = deviceMobile + } params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign_type"] = signTypeMD5 @@ -106,7 +111,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen params["cid"] = cid } if req.IsMobile { - params["device"] = "mobile" + params["device"] = deviceMobile } params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign_type"] = signTypeMD5 @@ -120,6 +125,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen Msg string `json:"msg"` TradeNo string `json:"trade_no"` PayURL string `json:"payurl"` + PayURL2 string `json:"payurl2"` // H5 mobile payment URL QRCode string `json:"qrcode"` } if err := json.Unmarshal(body, &resp); err != nil { @@ -128,7 +134,11 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen if resp.Code != easypayCodeSuccess { return nil, fmt.Errorf("easypay error: %s", resp.Msg) } - return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: resp.PayURL, QRCode: resp.QRCode}, nil + payURL := resp.PayURL + if req.IsMobile && resp.PayURL2 != "" { + payURL = resp.PayURL2 + } + return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil } // resolveURLs returns (notifyURL, returnURL) preferring request values, diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go index 58982878..4a68f3a9 100644 --- a/backend/internal/pkg/antigravity/stream_transformer.go +++ b/backend/internal/pkg/antigravity/stream_transformer.go @@ -18,6 +18,9 @@ const ( BlockTypeFunction ) +// UsageMapHook is a callback that can modify usage data before it's emitted in SSE events. +type UsageMapHook func(usageMap map[string]any) + // StreamingProcessor 流式响应处理器 type StreamingProcessor struct { blockType BlockType @@ -30,6 +33,7 @@ type StreamingProcessor struct { originalModel string webSearchQueries []string groundingChunks []GeminiGroundingChunk + usageMapHook UsageMapHook // 累计 usage inputTokens int @@ -46,6 +50,28 @@ func NewStreamingProcessor(originalModel string) *StreamingProcessor { } } +// SetUsageMapHook sets an optional hook that modifies usage maps before they are emitted. +func (p *StreamingProcessor) SetUsageMapHook(fn UsageMapHook) { + p.usageMapHook = fn +} + +func usageToMap(u ClaudeUsage) map[string]any { + m := map[string]any{ + "input_tokens": u.InputTokens, + "output_tokens": u.OutputTokens, + } + if u.CacheCreationInputTokens > 0 { + m["cache_creation_input_tokens"] = u.CacheCreationInputTokens + } + if u.CacheReadInputTokens > 0 { + m["cache_read_input_tokens"] = u.CacheReadInputTokens + } + if u.ImageOutputTokens > 0 { + m["image_output_tokens"] = u.ImageOutputTokens + } + return m +} + // ProcessLine 处理 SSE 行,返回 Claude SSE 事件 func (p *StreamingProcessor) ProcessLine(line string) []byte { line = strings.TrimSpace(line) @@ -172,6 +198,13 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte responseID = "msg_" + generateRandomID() } + var usageValue any = usage + if p.usageMapHook != nil { + usageMap := usageToMap(usage) + p.usageMapHook(usageMap) + usageValue = usageMap + } + message := map[string]any{ "id": responseID, "type": "message", @@ -180,7 +213,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte "model": p.originalModel, "stop_reason": nil, "stop_sequence": nil, - "usage": usage, + "usage": usageValue, } event := map[string]any{ @@ -492,13 +525,20 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte { ImageOutputTokens: p.imageOutputTokens, } + var usageValue any = usage + if p.usageMapHook != nil { + usageMap := usageToMap(usage) + p.usageMapHook(usageMap) + usageValue = usageMap + } + deltaEvent := map[string]any{ "type": "message_delta", "delta": map[string]any{ "stop_reason": stopReason, "stop_sequence": nil, }, - "usage": usage, + "usage": usageValue, } _, _ = result.Write(p.formatSSE("message_delta", deltaEvent)) diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index dc157a6d..c2725406 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -27,13 +27,14 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } out := &ResponsesRequest{ - Model: req.Model, - Input: inputJSON, - Temperature: req.Temperature, - TopP: req.TopP, - Stream: true, // upstream always streams - Include: []string{"reasoning.encrypted_content"}, - ServiceTier: req.ServiceTier, + Model: req.Model, + Instructions: req.Instructions, + Input: inputJSON, + Temperature: req.Temperature, + TopP: req.TopP, + Stream: true, // upstream always streams + Include: []string{"reasoning.encrypted_content"}, + ServiceTier: req.ServiceTier, } storeFalse := false diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b383f867..e0d1a53e 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -152,6 +152,7 @@ type AnthropicDelta struct { // ResponsesRequest is the request body for POST /v1/responses. type ResponsesRequest struct { Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` Input json.RawMessage `json:"input"` // string or []ResponsesInputItem MaxOutputTokens *int `json:"max_output_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` @@ -337,6 +338,7 @@ type ResponsesStreamEvent struct { type ChatCompletionsRequest struct { Model string `json:"model"` Messages []ChatMessage `json:"messages"` + Instructions string `json:"instructions,omitempty"` // OpenAI Responses API compat MaxTokens *int `json:"max_tokens,omitempty"` MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` diff --git a/backend/internal/pkg/logger/logger_test.go b/backend/internal/pkg/logger/logger_test.go index 74aae061..06a277a4 100644 --- a/backend/internal/pkg/logger/logger_test.go +++ b/backend/internal/pkg/logger/logger_test.go @@ -10,7 +10,13 @@ import ( ) func TestInit_DualOutput(t *testing.T) { - tmpDir := t.TempDir() + // Use os.MkdirTemp instead of t.TempDir to avoid cleanup failures + // when lumberjack holds file handles on Windows. + tmpDir, err := os.MkdirTemp("", "logger-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) logPath := filepath.Join(tmpDir, "logs", "sub2api.log") origStdout := os.Stdout @@ -57,7 +63,9 @@ func TestInit_DualOutput(t *testing.T) { L().Info("dual-output-info") L().Warn("dual-output-warn") - Sync() + + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). + // The log data is already in the pipe buffer; closing writers is sufficient. _ = stdoutW.Close() _ = stderrW.Close() @@ -166,7 +174,9 @@ func TestInit_CallerShouldPointToCallsite(t *testing.T) { } L().Info("caller-check") - Sync() + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). + os.Stdout = origStdout + os.Stderr = origStderr _ = stdoutW.Close() logBytes, _ := io.ReadAll(stdoutR) diff --git a/backend/internal/pkg/logger/stdlog_bridge_test.go b/backend/internal/pkg/logger/stdlog_bridge_test.go index 4482a2ec..30d25b33 100644 --- a/backend/internal/pkg/logger/stdlog_bridge_test.go +++ b/backend/internal/pkg/logger/stdlog_bridge_test.go @@ -77,7 +77,7 @@ func TestStdLogBridgeRoutesLevels(t *testing.T) { log.Printf("service started") log.Printf("Warning: queue full") log.Printf("Forward request failed: timeout") - Sync() + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). _ = stdoutW.Close() _ = stderrW.Close() @@ -139,7 +139,7 @@ func TestLegacyPrintfRoutesLevels(t *testing.T) { LegacyPrintf("service.test", "request started") LegacyPrintf("service.test", "Warning: queue full") LegacyPrintf("service.test", "forward failed: timeout") - Sync() + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). _ = stdoutW.Close() _ = stderrW.Close() diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index cd54baa3..2b6edad3 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -113,9 +113,11 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t } if cmd.BalanceCost > 0 { - if err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost); err != nil { + newBalance, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost) + if err != nil { return err } + result.NewBalance = &newBalance } if cmd.APIKeyQuotaCost > 0 { @@ -133,9 +135,11 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t } if cmd.AccountQuotaCost > 0 && (strings.EqualFold(cmd.AccountType, service.AccountTypeAPIKey) || strings.EqualFold(cmd.AccountType, service.AccountTypeBedrock)) { - if err := incrementUsageBillingAccountQuota(ctx, tx, cmd.AccountID, cmd.AccountQuotaCost); err != nil { + quotaState, err := incrementUsageBillingAccountQuota(ctx, tx, cmd.AccountID, cmd.AccountQuotaCost) + if err != nil { return err } + result.QuotaState = quotaState } return nil @@ -169,24 +173,22 @@ func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscrip return service.ErrSubscriptionNotFound } -func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) error { - res, err := tx.ExecContext(ctx, ` +func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, error) { + var newBalance float64 + err := tx.QueryRowContext(ctx, ` UPDATE users SET balance = balance - $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL - `, amount, userID) + RETURNING balance + `, amount, userID).Scan(&newBalance) + if errors.Is(err, sql.ErrNoRows) { + return 0, service.ErrUserNotFound + } if err != nil { - return err + return 0, err } - affected, err := res.RowsAffected() - if err != nil { - return err - } - if affected > 0 { - return nil - } - return service.ErrUserNotFound + return newBalance, nil } func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64) (bool, error) { @@ -240,7 +242,7 @@ func incrementUsageBillingAPIKeyRateLimit(ctx context.Context, tx *sql.Tx, apiKe return nil } -func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountID int64, amount float64) error { +func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountID int64, amount float64) (*service.AccountQuotaState, error) { rows, err := tx.QueryContext(ctx, `UPDATE accounts SET extra = ( COALESCE(extra, '{}'::jsonb) @@ -279,32 +281,40 @@ func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountI WHERE id = $2 AND deleted_at IS NULL RETURNING COALESCE((extra->>'quota_used')::numeric, 0), - COALESCE((extra->>'quota_limit')::numeric, 0)`, + COALESCE((extra->>'quota_limit')::numeric, 0), + COALESCE((extra->>'quota_daily_used')::numeric, 0), + COALESCE((extra->>'quota_daily_limit')::numeric, 0), + COALESCE((extra->>'quota_weekly_used')::numeric, 0), + COALESCE((extra->>'quota_weekly_limit')::numeric, 0)`, amount, accountID) if err != nil { - return err + return nil, err } defer func() { _ = rows.Close() }() - var newUsed, limit float64 + var state service.AccountQuotaState if rows.Next() { - if err := rows.Scan(&newUsed, &limit); err != nil { - return err + if err := rows.Scan( + &state.TotalUsed, &state.TotalLimit, + &state.DailyUsed, &state.DailyLimit, + &state.WeeklyUsed, &state.WeeklyLimit, + ); err != nil { + return nil, err } } else { if err := rows.Err(); err != nil { - return err + return nil, err } - return service.ErrAccountNotFound + return nil, service.ErrAccountNotFound } if err := rows.Err(); err != nil { - return err + return nil, err } - if limit > 0 && newUsed >= limit && (newUsed-amount) < limit { + if state.TotalLimit > 0 && state.TotalUsed >= state.TotalLimit && (state.TotalUsed-amount) < state.TotalLimit { if err := enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventAccountChanged, &accountID, nil, nil); err != nil { logger.LegacyPrintf("repository.usage_billing", "[SchedulerOutbox] enqueue quota exceeded failed: account=%d err=%v", accountID, err) - return err + return nil, err } } - return nil + return &state, nil } diff --git a/backend/internal/repository/user_group_rate_repo.go b/backend/internal/repository/user_group_rate_repo.go index e2471ae5..eca5313f 100644 --- a/backend/internal/repository/user_group_rate_repo.go +++ b/backend/internal/repository/user_group_rate_repo.go @@ -100,7 +100,7 @@ func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int6 query := ` SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, ugr.rate_multiplier FROM user_group_rate_multipliers ugr - JOIN users u ON u.id = ugr.user_id + JOIN users u ON u.id = ugr.user_id AND u.deleted_at IS NULL WHERE ugr.group_id = $1 ORDER BY ugr.user_id ` diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 8def7559..23bd58ad 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -26,6 +26,7 @@ func RegisterPaymentRoutes( authenticated.Use(middleware.BackendModeUserGuard(settingService)) { authenticated.GET("/config", paymentHandler.GetPaymentConfig) + authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo) authenticated.GET("/plans", paymentHandler.GetPlans) authenticated.GET("/channels", paymentHandler.GetChannels) authenticated.GET("/limits", paymentHandler.GetLimits) @@ -33,6 +34,7 @@ func RegisterPaymentRoutes( orders := authenticated.Group("/orders") { orders.POST("", paymentHandler.CreateOrder) + orders.POST("/verify", paymentHandler.VerifyOrder) orders.GET("/my", paymentHandler.GetMyOrders) orders.GET("/:id", paymentHandler.GetOrder) orders.POST("/:id/cancel", paymentHandler.CancelOrder) @@ -52,6 +54,8 @@ func RegisterPaymentRoutes( // --- Webhook endpoints (no auth) --- webhook := v1.Group("/payment/webhook") { + // EasyPay sends GET callbacks with query params + webhook.GET("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/alipay", webhookHandler.AlipayNotify) webhook.POST("/wxpay", webhookHandler.WxpayNotify) diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 25c6331a..2bd9a091 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "math/rand/v2" "time" @@ -99,7 +100,7 @@ func (s *APIKeyService) StartAuthCacheInvalidationSubscriber(ctx context.Context s.authCacheL1.Del(cacheKey) }); err != nil { // Log but don't fail - L1 cache will still work, just without cross-instance invalidation - println("[Service] Warning: failed to start auth cache invalidation subscriber:", err.Error()) + slog.Warn("failed to start auth cache invalidation subscriber", "error", err) } } diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index aa5e2ceb..c29550d9 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -81,9 +81,9 @@ type wildcardMappingEntry struct { type channelCache struct { // 热路径查找 pricingByGroupModel map[channelModelKey]*ChannelModelPricing // (groupID, platform, model) → 定价 - wildcardByGroupPlatform map[channelGroupPlatformKey][]*wildcardPricingEntry // (groupID, platform) → 通配符定价(前缀长度降序) + wildcardByGroupPlatform map[channelGroupPlatformKey][]*wildcardPricingEntry // (groupID, platform) → 通配符定价(按配置顺序,先匹配先使用) mappingByGroupModel map[channelModelKey]string // (groupID, platform, model) → 映射目标 - wildcardMappingByGP map[channelGroupPlatformKey][]*wildcardMappingEntry // (groupID, platform) → 通配符映射(前缀长度降序) + wildcardMappingByGP map[channelGroupPlatformKey][]*wildcardMappingEntry // (groupID, platform) → 通配符映射(按配置顺序,先匹配先使用) channelByGroupID map[int64]*Channel // groupID → 渠道 groupPlatform map[int64]string // groupID → platform @@ -680,6 +680,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) ModelPricing: input.ModelPricing, ModelMapping: input.ModelMapping, Features: input.Features, + FeaturesConfig: input.FeaturesConfig, ApplyPricingToAccountStats: input.ApplyPricingToAccountStats, AccountStatsPricingRules: input.AccountStatsPricingRules, } @@ -780,6 +781,9 @@ func (s *ChannelService) applyUpdateInput(ctx context.Context, channel *Channel, if input.BillingModelSource != "" { channel.BillingModelSource = input.BillingModelSource } + if input.FeaturesConfig != nil { + channel.FeaturesConfig = input.FeaturesConfig + } if input.ApplyPricingToAccountStats != nil { channel.ApplyPricingToAccountStats = *input.ApplyPricingToAccountStats } @@ -959,6 +963,7 @@ type CreateChannelInput struct { BillingModelSource string RestrictModels bool Features string + FeaturesConfig map[string]any ApplyPricingToAccountStats bool AccountStatsPricingRules []AccountStatsPricingRule } @@ -974,6 +979,7 @@ type UpdateChannelInput struct { BillingModelSource string RestrictModels *bool Features *string + FeaturesConfig map[string]any ApplyPricingToAccountStats *bool AccountStatsPricingRules *[]AccountStatsPricingRule } diff --git a/backend/internal/service/concurrency_service.go b/backend/internal/service/concurrency_service.go index 217b83d6..386d5ed0 100644 --- a/backend/internal/service/concurrency_service.go +++ b/backend/internal/service/concurrency_service.go @@ -343,8 +343,9 @@ func (s *ConcurrencyService) StartSlotCleanupWorker(accountRepo AccountRepositor }() } -// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts -// Returns a map of accountID -> current concurrency count +// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts. +// Uses a detached context with timeout to prevent HTTP request cancellation from +// causing the entire batch to fail (which would show all concurrency as 0). func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) { if len(accountIDs) == 0 { return map[int64]int{}, nil @@ -356,5 +357,11 @@ func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, acc } return result, nil } - return s.cache.GetAccountConcurrencyBatch(ctx, accountIDs) + + // Use a detached context so that a cancelled HTTP request doesn't cause + // the Redis pipeline to fail and return all-zero concurrency counts. + redisCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + return s.cache.GetAccountConcurrencyBatch(redisCtx, accountIDs) } diff --git a/backend/internal/service/gateway_websearch_emulation.go b/backend/internal/service/gateway_websearch_emulation.go index 0d0b5480..a42b5585 100644 --- a/backend/internal/service/gateway_websearch_emulation.go +++ b/backend/internal/service/gateway_websearch_emulation.go @@ -214,17 +214,23 @@ func writeWebSearchStreamResponse( ) (*ForwardResult, error) { msgID := webSearchMsgIDPrefix + uuid.New().String() toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16] + textSummary := buildTextSummary(query, resp.Results) setSSEHeaders(c) - if err := writeSSEMessageStart(c.Writer, msgID, model); err != nil { - return nil, fmt.Errorf("web search emulation: SSE write: %w", err) + w := c.Writer + for _, fn := range []func() error{ + func() error { return writeSSEMessageStart(w, msgID, model) }, + func() error { return writeSSEServerToolUse(w, toolUseID, query, 0) }, + func() error { return writeSSEToolResult(w, toolUseID, resp.Results, 1) }, + func() error { return writeSSETextBlock(w, textSummary, 2) }, + func() error { return writeSSEMessageEnd(w, len(textSummary)/tokenEstimateDivisor) }, + } { + if err := fn(); err != nil { + slog.Warn("web search emulation: SSE write failed, stopping", "error", err) + break + } } - writeSSEServerToolUse(c.Writer, toolUseID, query, 0) - writeSSEToolResult(c.Writer, toolUseID, resp.Results, 1) - textSummary := buildTextSummary(query, resp.Results) - writeSSETextBlock(c.Writer, textSummary, 2) - writeSSEMessageEnd(c.Writer, len(textSummary)/tokenEstimateDivisor) - c.Writer.Flush() + w.Flush() return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil } @@ -249,7 +255,7 @@ func writeSSEMessageStart(w http.ResponseWriter, msgID, model string) error { return flushSSEJSON(w, "message_start", evt) } -func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index int) { +func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index int) error { start := map[string]any{ "type": "content_block_start", "index": index, "content_block": map[string]any{ @@ -257,11 +263,13 @@ func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index "name": toolNameWebSearch, "input": map[string]string{"query": query}, }, } - _ = flushSSEJSON(w, "content_block_start", start) - _ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) + if err := flushSSEJSON(w, "content_block_start", start); err != nil { + return err + } + return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) } -func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []websearch.SearchResult, index int) { +func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []websearch.SearchResult, index int) error { start := map[string]any{ "type": "content_block_start", "index": index, "content_block": map[string]any{ @@ -269,40 +277,48 @@ func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []webse "content": buildSearchResultBlocks(results), }, } - _ = flushSSEJSON(w, "content_block_start", start) - _ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) + if err := flushSSEJSON(w, "content_block_start", start); err != nil { + return err + } + return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) } -func writeSSETextBlock(w http.ResponseWriter, text string, index int) { - _ = flushSSEJSON(w, "content_block_start", map[string]any{ +func writeSSETextBlock(w http.ResponseWriter, text string, index int) error { + if err := flushSSEJSON(w, "content_block_start", map[string]any{ "type": "content_block_start", "index": index, "content_block": map[string]any{"type": "text", "text": ""}, - }) - _ = flushSSEJSON(w, "content_block_delta", map[string]any{ + }); err != nil { + return err + } + if err := flushSSEJSON(w, "content_block_delta", map[string]any{ "type": "content_block_delta", "index": index, "delta": map[string]string{"type": "text_delta", "text": text}, - }) - _ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) + }); err != nil { + return err + } + return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) } -func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) { - _ = flushSSEJSON(w, "message_delta", map[string]any{ +func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) error { + if err := flushSSEJSON(w, "message_delta", map[string]any{ "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "usage": map[string]int{"output_tokens": outputTokens}, - }) - _ = flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"}) + }); err != nil { + return err + } + return flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"}) } -// flushSSEJSON marshals data to JSON and writes an SSE event. Returns error on marshal failure. +// flushSSEJSON marshals data to JSON and writes an SSE event. func flushSSEJSON(w http.ResponseWriter, event string, data any) error { b, err := json.Marshal(data) if err != nil { - slog.Error("web search emulation: failed to marshal SSE event", - "event", event, "error", err) - return err + return fmt.Errorf("marshal: %w", err) + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil { + return fmt.Errorf("write: %w", err) } - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b) if f, ok := w.(http.Flusher); ok { f.Flush() } diff --git a/backend/internal/service/ops_concurrency.go b/backend/internal/service/ops_concurrency.go index ad303d92..69b513af 100644 --- a/backend/internal/service/ops_concurrency.go +++ b/backend/internal/service/ops_concurrency.go @@ -64,12 +64,9 @@ func (s *OpsService) getAccountsLoadMapBestEffort(ctx context.Context, accounts if acc.ID <= 0 { continue } - c := acc.Concurrency - if c <= 0 { - c = 1 - } - if prev, ok := unique[acc.ID]; !ok || c > prev { - unique[acc.ID] = c + lf := acc.EffectiveLoadFactor() + if prev, ok := unique[acc.ID]; !ok || lf > prev { + unique[acc.ID] = lf } } diff --git a/backend/internal/service/ops_metrics_collector.go b/backend/internal/service/ops_metrics_collector.go index f93481e7..6c337071 100644 --- a/backend/internal/service/ops_metrics_collector.go +++ b/backend/internal/service/ops_metrics_collector.go @@ -391,7 +391,7 @@ func (c *OpsMetricsCollector) collectConcurrencyQueueDepth(parentCtx context.Con } batch = append(batch, AccountWithConcurrency{ ID: acc.ID, - MaxConcurrency: acc.Concurrency, + MaxConcurrency: acc.EffectiveLoadFactor(), }) } if len(batch) == 0 { diff --git a/backend/internal/service/ops_system_log_sink_test.go b/backend/internal/service/ops_system_log_sink_test.go index 12a2ec0c..137ee33c 100644 --- a/backend/internal/service/ops_system_log_sink_test.go +++ b/backend/internal/service/ops_system_log_sink_test.go @@ -183,6 +183,15 @@ func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) { if strings.TrimSpace(item.Message) == "" { t.Fatalf("message should not be empty") } + // writtenCount is incremented after BatchInsertSystemLogsFn returns, + // so poll briefly to avoid a race between the done signal and the atomic add. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if sink.Health().WrittenCount > 0 { + break + } + time.Sleep(time.Millisecond) + } health := sink.Health() if health.WrittenCount == 0 { t.Fatalf("written_count should be >0") diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 6753071d..bb161a14 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -113,11 +113,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe } func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { - return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx) + return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx) } func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { - return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx) + return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx) } func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) { diff --git a/backend/internal/service/websearch_config.go b/backend/internal/service/websearch_config.go index f528a35b..bb33368d 100644 --- a/backend/internal/service/websearch_config.go +++ b/backend/internal/service/websearch_config.go @@ -140,6 +140,16 @@ func (s *SettingService) SaveWebSearchEmulationConfig(ctx context.Context, cfg * } s.mergeExistingAPIKeys(ctx, cfg) + // After merge, validate all enabled providers have API keys + if cfg.Enabled { + for _, p := range cfg.Providers { + if p.APIKey == "" { + return infraerrors.BadRequest("MISSING_API_KEY", + fmt.Sprintf("provider %s has no API key configured", p.Type)) + } + } + } + data, err := json.Marshal(cfg) if err != nil { return fmt.Errorf("websearch: marshal config: %w", err) diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index a13eb3e1..f129ceaa 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -49,6 +49,7 @@ export interface Channel { status: string billing_model_source: string // "requested" | "upstream" restrict_models: boolean + features_config?: Record group_ids: number[] model_pricing: ChannelModelPricing[] model_mapping: Record> // platform → {src→dst} @@ -66,6 +67,7 @@ export interface CreateChannelRequest { model_mapping?: Record> billing_model_source?: string restrict_models?: boolean + features_config?: Record apply_pricing_to_account_stats?: boolean account_stats_pricing_rules?: AccountStatsPricingRule[] } @@ -79,6 +81,7 @@ export interface UpdateChannelRequest { model_mapping?: Record> billing_model_source?: string restrict_models?: boolean + features_config?: Record apply_pricing_to_account_stats?: boolean account_stats_pricing_rules?: AccountStatsPricingRule[] } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ebb58a21..59ccfcbe 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -429,8 +429,6 @@ export interface AdminGroup extends Group { // MCP XML 协议注入(仅 antigravity 平台使用) mcp_xml_inject: boolean - // Claude usage 模拟开关(仅 anthropic 平台使用) - simulate_claude_max_enabled: boolean // 支持的模型系列(仅 antigravity 平台使用) supported_model_scopes?: string[] @@ -523,7 +521,6 @@ export interface CreateGroupRequest { fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null mcp_xml_inject?: boolean - simulate_claude_max_enabled?: boolean supported_model_scopes?: string[] require_oauth_only?: boolean require_privacy_set?: boolean @@ -549,7 +546,6 @@ export interface UpdateGroupRequest { fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null mcp_xml_inject?: boolean - simulate_claude_max_enabled?: boolean supported_model_scopes?: string[] require_oauth_only?: boolean require_privacy_set?: boolean @@ -691,6 +687,7 @@ export interface Account { // Extra fields including Codex usage and model-level rate limits (Antigravity smart retry) extra?: (CodexUsageSnapshot & { model_rate_limits?: Record + antigravity_credits_overages?: Record } & Record) proxy_id: number | null concurrency: number @@ -752,12 +749,6 @@ export interface Account { custom_base_url_enabled?: boolean | null custom_base_url?: string | null - // 客户端亲和调度(仅 Anthropic/Antigravity 平台有效) - // 启用后新会话会优先调度到客户端之前使用过的账号 - client_affinity_enabled?: boolean | null - affinity_client_count?: number | null - affinity_clients?: string[] | null - // API Key 账号配额限制 quota_limit?: number | null quota_used?: number | null @@ -1066,6 +1057,8 @@ export interface AdminUsageLog extends UsageLog { // 账号计费倍率(仅管理员可见) account_rate_multiplier?: number | null + // 自定义定价规则计算的账号统计费用(nil 时使用 total_cost * multiplier) + account_stats_cost?: number | null // 渠道 ID 和计费等级(仅管理员可见) channel_id?: number | null diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index a85dd269..8ab08b73 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3253,6 +3253,7 @@ const editForm = reactive({ fallback_group_id_on_invalid_request: null as number | null, // OpenAI Messages 调度配置(仅 openai 平台使用) allow_messages_dispatch: false, + default_mapped_model: '', opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model, sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model, haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model, @@ -3732,6 +3733,19 @@ watch( }, ); +watch( + () => editForm.platform, + (newVal) => { + if (!['anthropic', 'antigravity'].includes(newVal)) { + editForm.fallback_group_id_on_invalid_request = null + } + if (newVal !== 'openai') { + editForm.allow_messages_dispatch = false + editForm.default_mapped_model = '' + } + } +) + // 点击外部关闭账号搜索下拉框 const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 7972f669..495ca7ad 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -495,7 +495,7 @@ const exportToExcel = async () => { log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000', log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4), log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000', - (log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms, + ((log.account_stats_cost ?? log.total_cost) * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms, log.request_id || '', log.user_agent || '', log.ip_address || '' ]) if (rows.length) { diff --git a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue index 28b82da5..876b2aa1 100644 --- a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue @@ -117,6 +117,7 @@ function getPlanNameClass(groupId: number): string { return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white' } + // ==================== Plans ==================== const plansLoading = ref(false) @@ -133,6 +134,7 @@ const planColumns = computed((): Column[] => [ { key: 'price', label: t('payment.admin.price') }, { key: 'validity_days', label: t('payment.admin.validityDays') }, { key: 'for_sale', label: t('payment.admin.forSale') }, + { key: 'sort_order', label: t('payment.admin.sortOrder') }, { key: 'actions', label: t('common.actions') }, ]) @@ -157,6 +159,7 @@ function openPlanEdit(plan: SubscriptionPlan | null) { showPlanDialog.value = true } + /** Quick toggle for_sale from the list */ async function toggleForSale(plan: SubscriptionPlan) { try { diff --git a/frontend/src/views/admin/orders/PlanEditDialog.vue b/frontend/src/views/admin/orders/PlanEditDialog.vue index 17c8084f..acc70bef 100644 --- a/frontend/src/views/admin/orders/PlanEditDialog.vue +++ b/frontend/src/views/admin/orders/PlanEditDialog.vue @@ -42,6 +42,9 @@
+
@@ -102,7 +105,7 @@ const { t } = useI18n() const appStore = useAppStore() const saving = ref(false) -const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true }) +const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true }) const planFeaturesText = ref('') const validityUnitOptions = computed(() => [ @@ -130,10 +133,10 @@ const selectedGroupInfo = computed(() => { watch(() => props.show, (visible) => { if (!visible) return if (props.plan) { - Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', for_sale: props.plan.for_sale }) + Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale }) planFeaturesText.value = (props.plan.features || []).join('\n') } else { - Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true }) + Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true }) planFeaturesText.value = '' } }) @@ -149,6 +152,7 @@ function buildPlanPayload() { original_price: planForm.original_price || 0, validity_days: planForm.validity_days, validity_unit: planForm.validity_unit, + sort_order: planForm.sort_order, for_sale: planForm.for_sale, features, } diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 3c7df572..bc16918c 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -102,10 +102,12 @@ interface ReturnInfo { } const returnInfo = ref(null) +const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING']) + const isSuccess = computed(() => { // Always prioritize actual order status from backend if (order.value) { - return order.value.status === 'COMPLETED' || order.value.status === 'PAID' + return SUCCESS_STATUSES.has(order.value.status) } // Fallback only when order not loaded if (route.query.status === 'success') return true diff --git a/frontend/src/views/user/UserOrdersView.vue b/frontend/src/views/user/UserOrdersView.vue index 51aacf7d..ea888eb7 100644 --- a/frontend/src/views/user/UserOrdersView.vue +++ b/frontend/src/views/user/UserOrdersView.vue @@ -22,7 +22,7 @@ {{ t('payment.orders.cancel') }} - @@ -102,6 +102,7 @@ const appStore = useAppStore() const loading = ref(false) const actionLoading = ref(false) const orders = ref([]) +const refundEligibleProviders = ref>(new Set()) const currentFilter = ref('') const cancelTargetId = ref(null) const refundTarget = ref(null) @@ -171,5 +172,18 @@ async function confirmRefund() { } } -onMounted(() => fetchOrders()) +function canRequestRefund(order: PaymentOrder): boolean { + if (order.status !== 'COMPLETED') return false + if (!order.provider_instance_id) return false + return refundEligibleProviders.value.has(order.provider_instance_id) +} + +async function loadRefundEligibility() { + try { + const res = await paymentAPI.getRefundEligibleProviders() + refundEligibleProviders.value = new Set(res.data.provider_instance_ids || []) + } catch { /* ignore — default to hiding refund button */ } +} + +onMounted(() => { fetchOrders(); loadRefundEligibility() })