fix: merge 30 general improvements from release branch

Bug fixes:
- Detached context for GetAccountConcurrencyBatch (prevent all-zero on request cancel)
- Filter soft-deleted users in GetByGroupID
- Stripe CSP policy (allow Stripe.js in script-src and frame-src)
- WebSearch API key validation on save
- RECHARGING status in payment result success check
- Windows test fixes (logger Sync deadlock, config path escaping)

Feature enhancements:
- Webhook multi-instance dispatch (extractOutTradeNo + GetWebhookProvider)
- EasyPay mobile H5 payment (device param + PayURL2)
- SSE error propagation in WebSearch emulation
- AccountStatsCost DTO field for admin usage logs
- Plans sort by sort_order instead of created_at
- UsageMapHook for streaming response usage data
- apicompat Instructions field passthrough
- EffectiveLoadFactor for ops concurrency/metrics
- Usage billing RETURNING balance for notify system
- BulkUpdate mixed channel warning with details
- println to slog migration in auth cache
- Wire ProviderSet cleanup
- CI cache-dependency-path optimization

Frontend:
- Refund eligibility check per provider (canRequestRefund)
- Plan sort_order editing
- Dead code cleanup (simulate_claude_max, client_affinity)
- GroupsView platform switch guard
- channels features_config API type
- UsageView account_stats_cost export
This commit is contained in:
erio
2026-04-14 17:35:27 +08:00
parent f1297a3694
commit 6ac8ccde46
34 changed files with 306 additions and 118 deletions
+2
View File
@@ -17,6 +17,7 @@ jobs:
go-version-file: backend/go.mod go-version-file: backend/go.mod
check-latest: false check-latest: false
cache: true cache: true
cache-dependency-path: backend/go.sum
- name: Verify Go version - name: Verify Go version
run: | run: |
go version | grep -q 'go1.26.2' go version | grep -q 'go1.26.2'
@@ -36,6 +37,7 @@ jobs:
go-version-file: backend/go.mod go-version-file: backend/go.mod
check-latest: false check-latest: false
cache: true cache: true
cache-dependency-path: backend/go.sum
- name: Verify Go version - name: Verify Go version
run: | run: |
go version | grep -q 'go1.26.2' go version | grep -q 'go1.26.2'
+1 -7
View File
@@ -36,19 +36,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Business layer ProviderSets // Business layer ProviderSets
repository.ProviderSet, repository.ProviderSet,
service.ProviderSet, service.ProviderSet,
payment.ProviderSet,
middleware.ProviderSet, middleware.ProviderSet,
handler.ProviderSet, handler.ProviderSet,
// Server layer ProviderSet // Server layer ProviderSet
server.ProviderSet, server.ProviderSet,
// Payment providers
payment.ProvideRegistry,
payment.ProvideEncryptionKey,
payment.ProvideDefaultLoadBalancer,
service.ProvidePaymentConfigService,
service.ProvidePaymentOrderExpiryService,
// Privacy client factory for OpenAI training opt-out // Privacy client factory for OpenAI training opt-out
providePrivacyClientFactory, providePrivacyClientFactory,
+1 -1
View File
@@ -28,7 +28,7 @@ const (
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support // 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 // __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(用户消息队列)模式常量 // UMQ(用户消息队列)模式常量
const ( const (
+3 -2
View File
@@ -233,12 +233,13 @@ func TestLoadForcedCodexInstructionsTemplate(t *testing.T) {
configPath := filepath.Join(tempDir, "config.yaml") configPath := filepath.Join(tempDir, "config.yaml")
require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644)) 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) t.Setenv("DATA_DIR", tempDir)
cfg, err := Load() cfg, err := Load()
require.NoError(t, err) 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) require.Equal(t, "server-prefix\n\n{{ .ExistingInstructions }}", cfg.Gateway.ForcedCodexInstructionsTemplate)
} }
@@ -1412,6 +1412,12 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
c.JSON(409, gin.H{ c.JSON(409, gin.H{
"error": "mixed_channel_warning", "error": "mixed_channel_warning",
"message": mixedErr.Error(), "message": mixedErr.Error(),
"details": gin.H{
"group_id": mixedErr.GroupID,
"group_name": mixedErr.GroupName,
"current_platform": mixedErr.CurrentPlatform,
"other_platform": mixedErr.OtherPlatform,
},
}) })
return return
} }
+1
View File
@@ -628,6 +628,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
ModelMappingChain: l.ModelMappingChain, ModelMappingChain: l.ModelMappingChain,
BillingTier: l.BillingTier, BillingTier: l.BillingTier,
AccountRateMultiplier: l.AccountRateMultiplier, AccountRateMultiplier: l.AccountRateMultiplier,
AccountStatsCost: l.AccountStatsCost,
IPAddress: l.IPAddress, IPAddress: l.IPAddress,
Account: AccountSummaryFromService(l.Account), Account: AccountSummaryFromService(l.Account),
} }
+2
View File
@@ -427,6 +427,8 @@ type AdminUsageLog struct {
// AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理) // AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理)
AccountRateMultiplier *float64 `json:"account_rate_multiplier"` AccountRateMultiplier *float64 `json:"account_rate_multiplier"`
// AccountStatsCost 自定义定价规则计算的账号统计费用(nil 表示使用默认公式)
AccountStatsCost *float64 `json:"account_stats_cost,omitempty"`
// IPAddress 用户请求 IP(仅管理员可见) // IPAddress 用户请求 IP(仅管理员可见)
IPAddress *string `json:"ip_address,omitempty"` IPAddress *string `json:"ip_address,omitempty"`
@@ -4,6 +4,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment"
@@ -72,9 +73,13 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
rawBody = string(body) 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 { 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) writeSuccessResponse(c, providerKey)
return return
} }
@@ -111,19 +116,40 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
writeSuccessResponse(c, providerKey) 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. // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
type wxpaySuccessResponse struct { type wxpaySuccessResponse struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} }
// WeChat Pay webhook success response constants.
const (
wxpaySuccessCode = "SUCCESS"
wxpaySuccessMessage = "成功"
)
// writeSuccessResponse sends the provider-specific success response. // writeSuccessResponse sends the provider-specific success response.
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; // WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
// Stripe expects an empty 200; others accept plain text "success". // Stripe expects an empty 200; others accept plain text "success".
func writeSuccessResponse(c *gin.Context, providerKey string) { func writeSuccessResponse(c *gin.Context, providerKey string) {
switch providerKey { switch providerKey {
case payment.TypeWxpay: case payment.TypeWxpay:
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"}) c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
case payment.TypeStripe: case payment.TypeStripe:
c.String(http.StatusOK, "") c.String(http.StatusOK, "")
default: default:
+13 -3
View File
@@ -27,6 +27,8 @@ const (
maxEasypayResponseSize = 1 << 20 // 1MB maxEasypayResponseSize = 1 << 20 // 1MB
tradeStatusSuccess = "TRADE_SUCCESS" tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5" signTypeMD5 = "MD5"
paymentModePopup = "popup"
deviceMobile = "mobile"
) )
// EasyPay implements payment.Provider for the EasyPay aggregation platform. // 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. // Payment mode determined by instance config, not payment type.
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php). // "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
mode := e.config["paymentMode"] mode := e.config["paymentMode"]
if mode == "popup" { if mode == paymentModePopup {
return e.createRedirectPayment(req) return e.createRedirectPayment(req)
} }
return e.createAPIPayment(ctx, 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 != "" { if cid := e.resolveCID(req.PaymentType); cid != "" {
params["cid"] = cid params["cid"] = cid
} }
if req.IsMobile {
params["device"] = deviceMobile
}
params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5 params["sign_type"] = signTypeMD5
@@ -106,7 +111,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
params["cid"] = cid params["cid"] = cid
} }
if req.IsMobile { if req.IsMobile {
params["device"] = "mobile" params["device"] = deviceMobile
} }
params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5 params["sign_type"] = signTypeMD5
@@ -120,6 +125,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
Msg string `json:"msg"` Msg string `json:"msg"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
PayURL string `json:"payurl"` PayURL string `json:"payurl"`
PayURL2 string `json:"payurl2"` // H5 mobile payment URL
QRCode string `json:"qrcode"` QRCode string `json:"qrcode"`
} }
if err := json.Unmarshal(body, &resp); err != nil { 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 { if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay error: %s", resp.Msg) 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, // resolveURLs returns (notifyURL, returnURL) preferring request values,
@@ -18,6 +18,9 @@ const (
BlockTypeFunction 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 流式响应处理器 // StreamingProcessor 流式响应处理器
type StreamingProcessor struct { type StreamingProcessor struct {
blockType BlockType blockType BlockType
@@ -30,6 +33,7 @@ type StreamingProcessor struct {
originalModel string originalModel string
webSearchQueries []string webSearchQueries []string
groundingChunks []GeminiGroundingChunk groundingChunks []GeminiGroundingChunk
usageMapHook UsageMapHook
// 累计 usage // 累计 usage
inputTokens int 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 事件 // ProcessLine 处理 SSE 行,返回 Claude SSE 事件
func (p *StreamingProcessor) ProcessLine(line string) []byte { func (p *StreamingProcessor) ProcessLine(line string) []byte {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -172,6 +198,13 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
responseID = "msg_" + generateRandomID() responseID = "msg_" + generateRandomID()
} }
var usageValue any = usage
if p.usageMapHook != nil {
usageMap := usageToMap(usage)
p.usageMapHook(usageMap)
usageValue = usageMap
}
message := map[string]any{ message := map[string]any{
"id": responseID, "id": responseID,
"type": "message", "type": "message",
@@ -180,7 +213,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
"model": p.originalModel, "model": p.originalModel,
"stop_reason": nil, "stop_reason": nil,
"stop_sequence": nil, "stop_sequence": nil,
"usage": usage, "usage": usageValue,
} }
event := map[string]any{ event := map[string]any{
@@ -492,13 +525,20 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
ImageOutputTokens: p.imageOutputTokens, ImageOutputTokens: p.imageOutputTokens,
} }
var usageValue any = usage
if p.usageMapHook != nil {
usageMap := usageToMap(usage)
p.usageMapHook(usageMap)
usageValue = usageMap
}
deltaEvent := map[string]any{ deltaEvent := map[string]any{
"type": "message_delta", "type": "message_delta",
"delta": map[string]any{ "delta": map[string]any{
"stop_reason": stopReason, "stop_reason": stopReason,
"stop_sequence": nil, "stop_sequence": nil,
}, },
"usage": usage, "usage": usageValue,
} }
_, _ = result.Write(p.formatSSE("message_delta", deltaEvent)) _, _ = result.Write(p.formatSSE("message_delta", deltaEvent))
@@ -27,13 +27,14 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest,
} }
out := &ResponsesRequest{ out := &ResponsesRequest{
Model: req.Model, Model: req.Model,
Input: inputJSON, Instructions: req.Instructions,
Temperature: req.Temperature, Input: inputJSON,
TopP: req.TopP, Temperature: req.Temperature,
Stream: true, // upstream always streams TopP: req.TopP,
Include: []string{"reasoning.encrypted_content"}, Stream: true, // upstream always streams
ServiceTier: req.ServiceTier, Include: []string{"reasoning.encrypted_content"},
ServiceTier: req.ServiceTier,
} }
storeFalse := false storeFalse := false
+2
View File
@@ -152,6 +152,7 @@ type AnthropicDelta struct {
// ResponsesRequest is the request body for POST /v1/responses. // ResponsesRequest is the request body for POST /v1/responses.
type ResponsesRequest struct { type ResponsesRequest struct {
Model string `json:"model"` Model string `json:"model"`
Instructions string `json:"instructions,omitempty"`
Input json.RawMessage `json:"input"` // string or []ResponsesInputItem Input json.RawMessage `json:"input"` // string or []ResponsesInputItem
MaxOutputTokens *int `json:"max_output_tokens,omitempty"` MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
@@ -337,6 +338,7 @@ type ResponsesStreamEvent struct {
type ChatCompletionsRequest struct { type ChatCompletionsRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []ChatMessage `json:"messages"` Messages []ChatMessage `json:"messages"`
Instructions string `json:"instructions,omitempty"` // OpenAI Responses API compat
MaxTokens *int `json:"max_tokens,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
+13 -3
View File
@@ -10,7 +10,13 @@ import (
) )
func TestInit_DualOutput(t *testing.T) { 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") logPath := filepath.Join(tmpDir, "logs", "sub2api.log")
origStdout := os.Stdout origStdout := os.Stdout
@@ -57,7 +63,9 @@ func TestInit_DualOutput(t *testing.T) {
L().Info("dual-output-info") L().Info("dual-output-info")
L().Warn("dual-output-warn") 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() _ = stdoutW.Close()
_ = stderrW.Close() _ = stderrW.Close()
@@ -166,7 +174,9 @@ func TestInit_CallerShouldPointToCallsite(t *testing.T) {
} }
L().Info("caller-check") L().Info("caller-check")
Sync() // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers).
os.Stdout = origStdout
os.Stderr = origStderr
_ = stdoutW.Close() _ = stdoutW.Close()
logBytes, _ := io.ReadAll(stdoutR) logBytes, _ := io.ReadAll(stdoutR)
@@ -77,7 +77,7 @@ func TestStdLogBridgeRoutesLevels(t *testing.T) {
log.Printf("service started") log.Printf("service started")
log.Printf("Warning: queue full") log.Printf("Warning: queue full")
log.Printf("Forward request failed: timeout") log.Printf("Forward request failed: timeout")
Sync() // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers).
_ = stdoutW.Close() _ = stdoutW.Close()
_ = stderrW.Close() _ = stderrW.Close()
@@ -139,7 +139,7 @@ func TestLegacyPrintfRoutesLevels(t *testing.T) {
LegacyPrintf("service.test", "request started") LegacyPrintf("service.test", "request started")
LegacyPrintf("service.test", "Warning: queue full") LegacyPrintf("service.test", "Warning: queue full")
LegacyPrintf("service.test", "forward failed: timeout") LegacyPrintf("service.test", "forward failed: timeout")
Sync() // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers).
_ = stdoutW.Close() _ = stdoutW.Close()
_ = stderrW.Close() _ = stderrW.Close()
@@ -113,9 +113,11 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
} }
if cmd.BalanceCost > 0 { 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 return err
} }
result.NewBalance = &newBalance
} }
if cmd.APIKeyQuotaCost > 0 { 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 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 return err
} }
result.QuotaState = quotaState
} }
return nil return nil
@@ -169,24 +173,22 @@ func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscrip
return service.ErrSubscriptionNotFound return service.ErrSubscriptionNotFound
} }
func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) error { func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, error) {
res, err := tx.ExecContext(ctx, ` var newBalance float64
err := tx.QueryRowContext(ctx, `
UPDATE users UPDATE users
SET balance = balance - $1, SET balance = balance - $1,
updated_at = NOW() updated_at = NOW()
WHERE id = $2 AND deleted_at IS NULL 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 { if err != nil {
return err return 0, err
} }
affected, err := res.RowsAffected() return newBalance, nil
if err != nil {
return err
}
if affected > 0 {
return nil
}
return service.ErrUserNotFound
} }
func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64) (bool, error) { 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 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, rows, err := tx.QueryContext(ctx,
`UPDATE accounts SET extra = ( `UPDATE accounts SET extra = (
COALESCE(extra, '{}'::jsonb) COALESCE(extra, '{}'::jsonb)
@@ -279,32 +281,40 @@ func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountI
WHERE id = $2 AND deleted_at IS NULL WHERE id = $2 AND deleted_at IS NULL
RETURNING RETURNING
COALESCE((extra->>'quota_used')::numeric, 0), 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) amount, accountID)
if err != nil { if err != nil {
return err return nil, err
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
var newUsed, limit float64 var state service.AccountQuotaState
if rows.Next() { if rows.Next() {
if err := rows.Scan(&newUsed, &limit); err != nil { if err := rows.Scan(
return err &state.TotalUsed, &state.TotalLimit,
&state.DailyUsed, &state.DailyLimit,
&state.WeeklyUsed, &state.WeeklyLimit,
); err != nil {
return nil, err
} }
} else { } else {
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return err return nil, err
} }
return service.ErrAccountNotFound return nil, service.ErrAccountNotFound
} }
if err := rows.Err(); err != nil { 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 { 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) 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
} }
@@ -100,7 +100,7 @@ func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int6
query := ` query := `
SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, ugr.rate_multiplier SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, ugr.rate_multiplier
FROM user_group_rate_multipliers ugr 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 WHERE ugr.group_id = $1
ORDER BY ugr.user_id ORDER BY ugr.user_id
` `
@@ -26,6 +26,7 @@ func RegisterPaymentRoutes(
authenticated.Use(middleware.BackendModeUserGuard(settingService)) authenticated.Use(middleware.BackendModeUserGuard(settingService))
{ {
authenticated.GET("/config", paymentHandler.GetPaymentConfig) authenticated.GET("/config", paymentHandler.GetPaymentConfig)
authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo)
authenticated.GET("/plans", paymentHandler.GetPlans) authenticated.GET("/plans", paymentHandler.GetPlans)
authenticated.GET("/channels", paymentHandler.GetChannels) authenticated.GET("/channels", paymentHandler.GetChannels)
authenticated.GET("/limits", paymentHandler.GetLimits) authenticated.GET("/limits", paymentHandler.GetLimits)
@@ -33,6 +34,7 @@ func RegisterPaymentRoutes(
orders := authenticated.Group("/orders") orders := authenticated.Group("/orders")
{ {
orders.POST("", paymentHandler.CreateOrder) orders.POST("", paymentHandler.CreateOrder)
orders.POST("/verify", paymentHandler.VerifyOrder)
orders.GET("/my", paymentHandler.GetMyOrders) orders.GET("/my", paymentHandler.GetMyOrders)
orders.GET("/:id", paymentHandler.GetOrder) orders.GET("/:id", paymentHandler.GetOrder)
orders.POST("/:id/cancel", paymentHandler.CancelOrder) orders.POST("/:id/cancel", paymentHandler.CancelOrder)
@@ -52,6 +54,8 @@ func RegisterPaymentRoutes(
// --- Webhook endpoints (no auth) --- // --- Webhook endpoints (no auth) ---
webhook := v1.Group("/payment/webhook") webhook := v1.Group("/payment/webhook")
{ {
// EasyPay sends GET callbacks with query params
webhook.GET("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/alipay", webhookHandler.AlipayNotify) webhook.POST("/alipay", webhookHandler.AlipayNotify)
webhook.POST("/wxpay", webhookHandler.WxpayNotify) webhook.POST("/wxpay", webhookHandler.WxpayNotify)
@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"math/rand/v2" "math/rand/v2"
"time" "time"
@@ -99,7 +100,7 @@ func (s *APIKeyService) StartAuthCacheInvalidationSubscriber(ctx context.Context
s.authCacheL1.Del(cacheKey) s.authCacheL1.Del(cacheKey)
}); err != nil { }); err != nil {
// Log but don't fail - L1 cache will still work, just without cross-instance invalidation // 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)
} }
} }
+8 -2
View File
@@ -81,9 +81,9 @@ type wildcardMappingEntry struct {
type channelCache struct { type channelCache struct {
// 热路径查找 // 热路径查找
pricingByGroupModel map[channelModelKey]*ChannelModelPricing // (groupID, platform, model) → 定价 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) → 映射目标 mappingByGroupModel map[channelModelKey]string // (groupID, platform, model) → 映射目标
wildcardMappingByGP map[channelGroupPlatformKey][]*wildcardMappingEntry // (groupID, platform) → 通配符映射(前缀长度降序 wildcardMappingByGP map[channelGroupPlatformKey][]*wildcardMappingEntry // (groupID, platform) → 通配符映射(按配置顺序,先匹配先使用
channelByGroupID map[int64]*Channel // groupID → 渠道 channelByGroupID map[int64]*Channel // groupID → 渠道
groupPlatform map[int64]string // groupID → platform groupPlatform map[int64]string // groupID → platform
@@ -680,6 +680,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
ModelPricing: input.ModelPricing, ModelPricing: input.ModelPricing,
ModelMapping: input.ModelMapping, ModelMapping: input.ModelMapping,
Features: input.Features, Features: input.Features,
FeaturesConfig: input.FeaturesConfig,
ApplyPricingToAccountStats: input.ApplyPricingToAccountStats, ApplyPricingToAccountStats: input.ApplyPricingToAccountStats,
AccountStatsPricingRules: input.AccountStatsPricingRules, AccountStatsPricingRules: input.AccountStatsPricingRules,
} }
@@ -780,6 +781,9 @@ func (s *ChannelService) applyUpdateInput(ctx context.Context, channel *Channel,
if input.BillingModelSource != "" { if input.BillingModelSource != "" {
channel.BillingModelSource = input.BillingModelSource channel.BillingModelSource = input.BillingModelSource
} }
if input.FeaturesConfig != nil {
channel.FeaturesConfig = input.FeaturesConfig
}
if input.ApplyPricingToAccountStats != nil { if input.ApplyPricingToAccountStats != nil {
channel.ApplyPricingToAccountStats = *input.ApplyPricingToAccountStats channel.ApplyPricingToAccountStats = *input.ApplyPricingToAccountStats
} }
@@ -959,6 +963,7 @@ type CreateChannelInput struct {
BillingModelSource string BillingModelSource string
RestrictModels bool RestrictModels bool
Features string Features string
FeaturesConfig map[string]any
ApplyPricingToAccountStats bool ApplyPricingToAccountStats bool
AccountStatsPricingRules []AccountStatsPricingRule AccountStatsPricingRules []AccountStatsPricingRule
} }
@@ -974,6 +979,7 @@ type UpdateChannelInput struct {
BillingModelSource string BillingModelSource string
RestrictModels *bool RestrictModels *bool
Features *string Features *string
FeaturesConfig map[string]any
ApplyPricingToAccountStats *bool ApplyPricingToAccountStats *bool
AccountStatsPricingRules *[]AccountStatsPricingRule AccountStatsPricingRules *[]AccountStatsPricingRule
} }
@@ -343,8 +343,9 @@ func (s *ConcurrencyService) StartSlotCleanupWorker(accountRepo AccountRepositor
}() }()
} }
// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts // GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts.
// Returns a map of accountID -> current concurrency count // 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) { func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
if len(accountIDs) == 0 { if len(accountIDs) == 0 {
return map[int64]int{}, nil return map[int64]int{}, nil
@@ -356,5 +357,11 @@ func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, acc
} }
return result, nil 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)
} }
@@ -214,17 +214,23 @@ func writeWebSearchStreamResponse(
) (*ForwardResult, error) { ) (*ForwardResult, error) {
msgID := webSearchMsgIDPrefix + uuid.New().String() msgID := webSearchMsgIDPrefix + uuid.New().String()
toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16] toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16]
textSummary := buildTextSummary(query, resp.Results)
setSSEHeaders(c) setSSEHeaders(c)
if err := writeSSEMessageStart(c.Writer, msgID, model); err != nil { w := c.Writer
return nil, fmt.Errorf("web search emulation: SSE write: %w", err) 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) w.Flush()
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()
return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil 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) 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{ start := map[string]any{
"type": "content_block_start", "index": index, "type": "content_block_start", "index": index,
"content_block": map[string]any{ "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}, "name": toolNameWebSearch, "input": map[string]string{"query": query},
}, },
} }
_ = flushSSEJSON(w, "content_block_start", start) if err := flushSSEJSON(w, "content_block_start", start); err != nil {
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) 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{ start := map[string]any{
"type": "content_block_start", "index": index, "type": "content_block_start", "index": index,
"content_block": map[string]any{ "content_block": map[string]any{
@@ -269,40 +277,48 @@ func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []webse
"content": buildSearchResultBlocks(results), "content": buildSearchResultBlocks(results),
}, },
} }
_ = flushSSEJSON(w, "content_block_start", start) if err := flushSSEJSON(w, "content_block_start", start); err != nil {
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) 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) { func writeSSETextBlock(w http.ResponseWriter, text string, index int) error {
_ = flushSSEJSON(w, "content_block_start", map[string]any{ if err := flushSSEJSON(w, "content_block_start", map[string]any{
"type": "content_block_start", "index": index, "type": "content_block_start", "index": index,
"content_block": map[string]any{"type": "text", "text": ""}, "content_block": map[string]any{"type": "text", "text": ""},
}) }); err != nil {
_ = flushSSEJSON(w, "content_block_delta", map[string]any{ return err
}
if err := flushSSEJSON(w, "content_block_delta", map[string]any{
"type": "content_block_delta", "index": index, "type": "content_block_delta", "index": index,
"delta": map[string]string{"type": "text_delta", "text": text}, "delta": map[string]string{"type": "text_delta", "text": text},
}) }); err != nil {
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) return err
}
return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
} }
func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) { func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) error {
_ = flushSSEJSON(w, "message_delta", map[string]any{ if err := flushSSEJSON(w, "message_delta", map[string]any{
"type": "message_delta", "type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]int{"output_tokens": outputTokens}, "usage": map[string]int{"output_tokens": outputTokens},
}) }); err != nil {
_ = flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"}) 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 { func flushSSEJSON(w http.ResponseWriter, event string, data any) error {
b, err := json.Marshal(data) b, err := json.Marshal(data)
if err != nil { if err != nil {
slog.Error("web search emulation: failed to marshal SSE event", return fmt.Errorf("marshal: %w", err)
"event", event, "error", err) }
return 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 { if f, ok := w.(http.Flusher); ok {
f.Flush() f.Flush()
} }
+3 -6
View File
@@ -64,12 +64,9 @@ func (s *OpsService) getAccountsLoadMapBestEffort(ctx context.Context, accounts
if acc.ID <= 0 { if acc.ID <= 0 {
continue continue
} }
c := acc.Concurrency lf := acc.EffectiveLoadFactor()
if c <= 0 { if prev, ok := unique[acc.ID]; !ok || lf > prev {
c = 1 unique[acc.ID] = lf
}
if prev, ok := unique[acc.ID]; !ok || c > prev {
unique[acc.ID] = c
} }
} }
@@ -391,7 +391,7 @@ func (c *OpsMetricsCollector) collectConcurrencyQueueDepth(parentCtx context.Con
} }
batch = append(batch, AccountWithConcurrency{ batch = append(batch, AccountWithConcurrency{
ID: acc.ID, ID: acc.ID,
MaxConcurrency: acc.Concurrency, MaxConcurrency: acc.EffectiveLoadFactor(),
}) })
} }
if len(batch) == 0 { if len(batch) == 0 {
@@ -183,6 +183,15 @@ func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) {
if strings.TrimSpace(item.Message) == "" { if strings.TrimSpace(item.Message) == "" {
t.Fatalf("message should not be empty") 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() health := sink.Health()
if health.WrittenCount == 0 { if health.WrittenCount == 0 {
t.Fatalf("written_count should be >0") t.Fatalf("written_count should be >0")
@@ -113,11 +113,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
} }
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { 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) { 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) { func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
@@ -140,6 +140,16 @@ func (s *SettingService) SaveWebSearchEmulationConfig(ctx context.Context, cfg *
} }
s.mergeExistingAPIKeys(ctx, 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) data, err := json.Marshal(cfg)
if err != nil { if err != nil {
return fmt.Errorf("websearch: marshal config: %w", err) return fmt.Errorf("websearch: marshal config: %w", err)
+3
View File
@@ -49,6 +49,7 @@ export interface Channel {
status: string status: string
billing_model_source: string // "requested" | "upstream" billing_model_source: string // "requested" | "upstream"
restrict_models: boolean restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[] group_ids: number[]
model_pricing: ChannelModelPricing[] model_pricing: ChannelModelPricing[]
model_mapping: Record<string, Record<string, string>> // platform → {src→dst} model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
@@ -66,6 +67,7 @@ export interface CreateChannelRequest {
model_mapping?: Record<string, Record<string, string>> model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string billing_model_source?: string
restrict_models?: boolean restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[] account_stats_pricing_rules?: AccountStatsPricingRule[]
} }
@@ -79,6 +81,7 @@ export interface UpdateChannelRequest {
model_mapping?: Record<string, Record<string, string>> model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string billing_model_source?: string
restrict_models?: boolean restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[] account_stats_pricing_rules?: AccountStatsPricingRule[]
} }
+3 -10
View File
@@ -429,8 +429,6 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用) // MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean mcp_xml_inject: boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled: boolean
// 支持的模型系列(仅 antigravity 平台使用) // 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[] supported_model_scopes?: string[]
@@ -523,7 +521,6 @@ export interface CreateGroupRequest {
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[] supported_model_scopes?: string[]
require_oauth_only?: boolean require_oauth_only?: boolean
require_privacy_set?: boolean require_privacy_set?: boolean
@@ -549,7 +546,6 @@ export interface UpdateGroupRequest {
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[] supported_model_scopes?: string[]
require_oauth_only?: boolean require_oauth_only?: boolean
require_privacy_set?: 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 fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & { extra?: (CodexUsageSnapshot & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }> model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
} & Record<string, unknown>) } & Record<string, unknown>)
proxy_id: number | null proxy_id: number | null
concurrency: number concurrency: number
@@ -752,12 +749,6 @@ export interface Account {
custom_base_url_enabled?: boolean | null custom_base_url_enabled?: boolean | null
custom_base_url?: string | null custom_base_url?: string | null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
affinity_client_count?: number | null
affinity_clients?: string[] | null
// API Key 账号配额限制 // API Key 账号配额限制
quota_limit?: number | null quota_limit?: number | null
quota_used?: number | null quota_used?: number | null
@@ -1066,6 +1057,8 @@ export interface AdminUsageLog extends UsageLog {
// 账号计费倍率(仅管理员可见) // 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null account_rate_multiplier?: number | null
// 自定义定价规则计算的账号统计费用(nil 时使用 total_cost * multiplier
account_stats_cost?: number | null
// 渠道 ID 和计费等级(仅管理员可见) // 渠道 ID 和计费等级(仅管理员可见)
channel_id?: number | null channel_id?: number | null
+14
View File
@@ -3253,6 +3253,7 @@ const editForm = reactive({
fallback_group_id_on_invalid_request: null as number | null, fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages openai 使 // OpenAI Messages openai 使
allow_messages_dispatch: false, allow_messages_dispatch: false,
default_mapped_model: '',
opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model, opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model,
sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model, sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model,
haiku_mapped_model: editMessagesDispatchDefaults.haiku_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 handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
+1 -1
View File
@@ -495,7 +495,7 @@ const exportToExcel = async () => {
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000', 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.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?.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 || '' log.request_id || '', log.user_agent || '', log.ip_address || ''
]) ])
if (rows.length) { if (rows.length) {
@@ -117,6 +117,7 @@ function getPlanNameClass(groupId: number): string {
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white' return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
} }
// ==================== Plans ==================== // ==================== Plans ====================
const plansLoading = ref(false) const plansLoading = ref(false)
@@ -133,6 +134,7 @@ const planColumns = computed((): Column[] => [
{ key: 'price', label: t('payment.admin.price') }, { key: 'price', label: t('payment.admin.price') },
{ key: 'validity_days', label: t('payment.admin.validityDays') }, { key: 'validity_days', label: t('payment.admin.validityDays') },
{ key: 'for_sale', label: t('payment.admin.forSale') }, { key: 'for_sale', label: t('payment.admin.forSale') },
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
{ key: 'actions', label: t('common.actions') }, { key: 'actions', label: t('common.actions') },
]) ])
@@ -157,6 +159,7 @@ function openPlanEdit(plan: SubscriptionPlan | null) {
showPlanDialog.value = true showPlanDialog.value = true
} }
/** Quick toggle for_sale from the list */ /** Quick toggle for_sale from the list */
async function toggleForSale(plan: SubscriptionPlan) { async function toggleForSale(plan: SubscriptionPlan) {
try { try {
@@ -42,6 +42,9 @@
<div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div> <div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div> <div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div> </div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div> <div>
<label class="input-label">{{ t('payment.admin.features') }}</label> <label class="input-label">{{ t('payment.admin.features') }}</label>
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea> <textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
@@ -102,7 +105,7 @@ const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const saving = ref(false) 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 planFeaturesText = ref('')
const validityUnitOptions = computed(() => [ const validityUnitOptions = computed(() => [
@@ -130,10 +133,10 @@ const selectedGroupInfo = computed(() => {
watch(() => props.show, (visible) => { watch(() => props.show, (visible) => {
if (!visible) return if (!visible) return
if (props.plan) { 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') planFeaturesText.value = (props.plan.features || []).join('\n')
} else { } 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 = '' planFeaturesText.value = ''
} }
}) })
@@ -149,6 +152,7 @@ function buildPlanPayload() {
original_price: planForm.original_price || 0, original_price: planForm.original_price || 0,
validity_days: planForm.validity_days, validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit, validity_unit: planForm.validity_unit,
sort_order: planForm.sort_order,
for_sale: planForm.for_sale, for_sale: planForm.for_sale,
features, features,
} }
@@ -102,10 +102,12 @@ interface ReturnInfo {
} }
const returnInfo = ref<ReturnInfo | null>(null) const returnInfo = ref<ReturnInfo | null>(null)
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
const isSuccess = computed(() => { const isSuccess = computed(() => {
// Always prioritize actual order status from backend // Always prioritize actual order status from backend
if (order.value) { 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 // Fallback only when order not loaded
if (route.query.status === 'success') return true if (route.query.status === 'success') return true
+16 -2
View File
@@ -22,7 +22,7 @@
<Icon name="x" size="sm" /> <Icon name="x" size="sm" />
<span>{{ t('payment.orders.cancel') }}</span> <span>{{ t('payment.orders.cancel') }}</span>
</button> </button>
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20"> <button v-if="canRequestRefund(row)" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="dollar" size="sm" /> <Icon name="dollar" size="sm" />
<span>{{ t('payment.orders.requestRefund') }}</span> <span>{{ t('payment.orders.requestRefund') }}</span>
</button> </button>
@@ -102,6 +102,7 @@ const appStore = useAppStore()
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
const orders = ref<PaymentOrder[]>([]) const orders = ref<PaymentOrder[]>([])
const refundEligibleProviders = ref<Set<string>>(new Set())
const currentFilter = ref('') const currentFilter = ref('')
const cancelTargetId = ref<number | null>(null) const cancelTargetId = ref<number | null>(null)
const refundTarget = ref<PaymentOrder | null>(null) const refundTarget = ref<PaymentOrder | null>(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() })
</script> </script>