Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1593 lines
57 KiB
Go
1593 lines
57 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
"unsafe"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||
"github.com/cespare/xxhash/v2"
|
||
gocache "github.com/patrickmn/go-cache"
|
||
"github.com/tidwall/gjson"
|
||
"github.com/tidwall/sjson"
|
||
"golang.org/x/sync/singleflight"
|
||
)
|
||
|
||
const (
|
||
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
|
||
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
|
||
stickySessionTTL = time.Hour // 粘性会话TTL
|
||
defaultMaxLineSize = 500 * 1024 * 1024
|
||
// Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
|
||
// to match real Claude CLI traffic as closely as possible. When we need a visual
|
||
// separator between system blocks, we add "\n\n" at concatenation time.
|
||
claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||
// claudeCodeSystemPromptExpansion 是真实 Claude Code 主系统提示词中"与具体工具无关"
|
||
// 的通用段落(身份/用途总述 + 安全声明 + URL 告警 + Tone and style),逐字取自真实
|
||
// CLI(2.1.x 一致)。伪装路径用它把 system 块数从 2 提升到 3、体量贴近真实 CC,同时
|
||
// 刻意排除 # Doing tasks / # Using your tools / # Executing actions 等会污染被代理
|
||
// 用户行为的工具专属指令。
|
||
claudeCodeSystemPromptExpansion = `You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||
|
||
IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
|
||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||
|
||
# Tone and style
|
||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||
- Your responses should be short and concise.
|
||
- When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.
|
||
- When referencing GitHub issues or pull requests, use the owner/repo#123 format (e.g. anthropics/claude-code#100) so they render as clickable links.
|
||
- Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period.`
|
||
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
|
||
|
||
defaultUserGroupRateCacheTTL = 30 * time.Second
|
||
defaultModelsListCacheTTL = 15 * time.Second
|
||
postUsageBillingTimeout = 15 * time.Second
|
||
claudeCodeNoopDeltaKeepaliveMinVersion = "2.1.193"
|
||
debugGatewayBodyEnv = "SUB2API_DEBUG_GATEWAY_BODY"
|
||
// 上游错误体只需要提取错误 JSON/日志摘要,默认 512KiB 避免错误风暴叠加大请求体。
|
||
gatewayUpstreamErrorBodyReadLimit int64 = 512 << 10
|
||
)
|
||
|
||
const (
|
||
claudeMimicDebugInfoKey = "claude_mimic_debug_info"
|
||
)
|
||
|
||
const (
|
||
cacheTTLTarget5m = "5m"
|
||
cacheTTLTarget1h = "1h"
|
||
)
|
||
|
||
// ForceCacheBillingContextKey 强制缓存计费上下文键
|
||
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
|
||
type forceCacheBillingKeyType struct{}
|
||
|
||
// accountWithLoad 账号与负载信息的组合,用于负载感知调度
|
||
type accountWithLoad struct {
|
||
account *Account
|
||
loadInfo *AccountLoadInfo
|
||
}
|
||
|
||
var ForceCacheBillingContextKey = forceCacheBillingKeyType{}
|
||
|
||
var (
|
||
windowCostPrefetchCacheHitTotal atomic.Int64
|
||
windowCostPrefetchCacheMissTotal atomic.Int64
|
||
windowCostPrefetchBatchSQLTotal atomic.Int64
|
||
windowCostPrefetchFallbackTotal atomic.Int64
|
||
windowCostPrefetchErrorTotal atomic.Int64
|
||
|
||
userGroupRateCacheHitTotal atomic.Int64
|
||
userGroupRateCacheMissTotal atomic.Int64
|
||
userGroupRateCacheLoadTotal atomic.Int64
|
||
userGroupRateCacheSFSharedTotal atomic.Int64
|
||
userGroupRateCacheFallbackTotal atomic.Int64
|
||
|
||
modelsListCacheHitTotal atomic.Int64
|
||
modelsListCacheMissTotal atomic.Int64
|
||
modelsListCacheStoreTotal atomic.Int64
|
||
|
||
// Deprecated: flusher_enabled=true 后不再增长(仅 flag=false 降级直写路径使用);新主路径见 FlusherMetrics。remove after 2026-09。
|
||
// userPlatformQuotaDBIncrErrorTotal 统计 finalizePostUsageBilling 异步 goroutine
|
||
// 中 IncrementUsageWithReset 失败次数。Redis 已成功累加 + DB 写失败意味着
|
||
// Redis cache TTL 过期或被清后该笔 cost 会丢失(与实际消费偏差)。
|
||
// oncall 通过 GatewayUserPlatformQuotaIncrStats() 暴露给 ops 面板做阈值告警。
|
||
userPlatformQuotaDBIncrErrorTotal atomic.Int64
|
||
// Deprecated: flusher_enabled=true 后不再增长(仅 flag=false 降级直写路径使用);新主路径见 FlusherMetrics。remove after 2026-09。
|
||
// userPlatformQuotaDBIncrLegacyErrorTotal 统计 legacy postUsageBilling
|
||
// (applyUsageBilling 在 repo==nil 时 fallback)路径下的失败次数;
|
||
// 与 DB Incr 失败分开计数,便于区分"主路径暂时故障"vs"基础设施长期未配齐"。
|
||
userPlatformQuotaDBIncrLegacyErrorTotal atomic.Int64
|
||
// userPlatformQuotaSentinelSetCacheErrorTotal 统计 checkUserPlatformQuotaEligibility
|
||
// 在 DB 无行时回填 sentinel cache entry 写 Redis 失败的次数(phase A)。
|
||
userPlatformQuotaSentinelSetCacheErrorTotal atomic.Int64
|
||
)
|
||
|
||
func GatewayWindowCostPrefetchStats() (cacheHit, cacheMiss, batchSQL, fallback, errCount int64) {
|
||
return windowCostPrefetchCacheHitTotal.Load(),
|
||
windowCostPrefetchCacheMissTotal.Load(),
|
||
windowCostPrefetchBatchSQLTotal.Load(),
|
||
windowCostPrefetchFallbackTotal.Load(),
|
||
windowCostPrefetchErrorTotal.Load()
|
||
}
|
||
|
||
func GatewayUserGroupRateCacheStats() (cacheHit, cacheMiss, load, singleflightShared, fallback int64) {
|
||
return userGroupRateCacheHitTotal.Load(),
|
||
userGroupRateCacheMissTotal.Load(),
|
||
userGroupRateCacheLoadTotal.Load(),
|
||
userGroupRateCacheSFSharedTotal.Load(),
|
||
userGroupRateCacheFallbackTotal.Load()
|
||
}
|
||
|
||
func GatewayModelsListCacheStats() (cacheHit, cacheMiss, store int64) {
|
||
return modelsListCacheHitTotal.Load(), modelsListCacheMissTotal.Load(), modelsListCacheStoreTotal.Load()
|
||
}
|
||
|
||
// GatewayUserPlatformQuotaIncrStats 返回 (mainPathErr, legacyPathErr, sentinelSetErr)。
|
||
// mainPathErr:finalizePostUsageBilling 异步 goroutine 写 DB 失败累计次数;
|
||
// legacyPathErr:postUsageBilling fallback 路径写 DB 失败累计次数;
|
||
// sentinelSetErr:DB 无行时回填 sentinel cache entry 写 Redis 失败累计次数。
|
||
// ops 监控面板可以按"持续上升斜率"做告警阈值。
|
||
func GatewayUserPlatformQuotaIncrStats() (mainPathErr, legacyPathErr, sentinelSetErr int64) {
|
||
return userPlatformQuotaDBIncrErrorTotal.Load(),
|
||
userPlatformQuotaDBIncrLegacyErrorTotal.Load(),
|
||
userPlatformQuotaSentinelSetCacheErrorTotal.Load()
|
||
}
|
||
|
||
// GatewayUserPlatformQuotaFlusherStats 暴露 flusher 运行指标供 ops/health 面板查询。
|
||
func GatewayUserPlatformQuotaFlusherStats(f *UserPlatformQuotaUsageFlusher) map[string]int64 {
|
||
if f == nil || f.metrics == nil {
|
||
return nil
|
||
}
|
||
m := f.metrics
|
||
return map[string]int64{
|
||
"flush_success": m.FlushSuccessTotal.Load(),
|
||
"flush_error": m.FlushErrorTotal.Load(),
|
||
"flush_batch_size": m.FlushBatchSizeTotal.Load(),
|
||
"flush_latency_ms_max": m.FlushLatencyMsMax.Load(),
|
||
"dirty_readd": m.DirtyReaddTotal.Load(),
|
||
"dirty_lost": m.DirtyLostTotal.Load(),
|
||
"flush_fk_violation": m.FlushFKViolationTotal.Load(),
|
||
}
|
||
}
|
||
|
||
func openAIStreamEventIsTerminal(data string) bool {
|
||
trimmed := strings.TrimSpace(data)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
if trimmed == "[DONE]" {
|
||
return true
|
||
}
|
||
return openAIStreamEventTypeIsTerminal(gjson.Get(trimmed, "type").String())
|
||
}
|
||
|
||
// openAIStreamEventIsTerminalWithType 复用已提取的 type,避免 SSE 热路径重复扫描 JSON。
|
||
func openAIStreamEventIsTerminalWithType(data, eventType string) bool {
|
||
trimmed := strings.TrimSpace(data)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
if trimmed == "[DONE]" {
|
||
return true
|
||
}
|
||
return openAIStreamEventTypeIsTerminal(eventType)
|
||
}
|
||
|
||
func openAIStreamEventTypeIsTerminal(eventType string) bool {
|
||
switch eventType {
|
||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func anthropicStreamEventIsTerminal(eventName, data string) bool {
|
||
if strings.EqualFold(strings.TrimSpace(eventName), "message_stop") {
|
||
return true
|
||
}
|
||
trimmed := strings.TrimSpace(data)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
if trimmed == "[DONE]" {
|
||
return true
|
||
}
|
||
return gjson.Get(trimmed, "type").String() == "message_stop"
|
||
}
|
||
|
||
func cloneStringSlice(src []string) []string {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]string, len(src))
|
||
copy(dst, src)
|
||
return dst
|
||
}
|
||
|
||
// IsForceCacheBilling 检查是否启用强制缓存计费
|
||
func IsForceCacheBilling(ctx context.Context) bool {
|
||
v, _ := ctx.Value(ForceCacheBillingContextKey).(bool)
|
||
return v
|
||
}
|
||
|
||
// WithForceCacheBilling 返回带有强制缓存计费标记的上下文
|
||
func WithForceCacheBilling(ctx context.Context) context.Context {
|
||
return context.WithValue(ctx, ForceCacheBillingContextKey, true)
|
||
}
|
||
|
||
func (s *GatewayService) debugModelRoutingEnabled() bool {
|
||
if s == nil {
|
||
return false
|
||
}
|
||
return s.debugModelRouting.Load()
|
||
}
|
||
|
||
func (s *GatewayService) debugClaudeMimicEnabled() bool {
|
||
if s == nil {
|
||
return false
|
||
}
|
||
return s.debugClaudeMimic.Load()
|
||
}
|
||
|
||
func parseDebugEnvBool(raw string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "1", "true", "yes", "on":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func shortSessionHash(sessionHash string) string {
|
||
if sessionHash == "" {
|
||
return ""
|
||
}
|
||
if len(sessionHash) <= 8 {
|
||
return sessionHash
|
||
}
|
||
return sessionHash[:8]
|
||
}
|
||
|
||
func redactAuthHeaderValue(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return ""
|
||
}
|
||
// Keep scheme for debugging, redact secret.
|
||
if strings.HasPrefix(strings.ToLower(v), "bearer ") {
|
||
return "Bearer [redacted]"
|
||
}
|
||
return "[redacted]"
|
||
}
|
||
|
||
func safeHeaderValueForLog(key string, v string) string {
|
||
key = strings.ToLower(strings.TrimSpace(key))
|
||
switch key {
|
||
case "authorization", "x-api-key":
|
||
return redactAuthHeaderValue(v)
|
||
default:
|
||
return strings.TrimSpace(v)
|
||
}
|
||
}
|
||
|
||
func extractSystemPreviewFromBody(body []byte) string {
|
||
if len(body) == 0 {
|
||
return ""
|
||
}
|
||
sys := gjson.GetBytes(body, "system")
|
||
if !sys.Exists() {
|
||
return ""
|
||
}
|
||
|
||
switch {
|
||
case sys.IsArray():
|
||
for _, item := range sys.Array() {
|
||
if !item.IsObject() {
|
||
continue
|
||
}
|
||
if strings.EqualFold(item.Get("type").String(), "text") {
|
||
if t := item.Get("text").String(); strings.TrimSpace(t) != "" {
|
||
return t
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
case sys.Type == gjson.String:
|
||
return sys.String()
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func buildClaudeMimicDebugLine(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) string {
|
||
if req == nil {
|
||
return ""
|
||
}
|
||
|
||
// Only log a minimal fingerprint to avoid leaking user content.
|
||
interesting := []string{
|
||
"user-agent",
|
||
"x-app",
|
||
"anthropic-dangerous-direct-browser-access",
|
||
"anthropic-version",
|
||
"anthropic-beta",
|
||
"x-stainless-lang",
|
||
"x-stainless-package-version",
|
||
"x-stainless-os",
|
||
"x-stainless-arch",
|
||
"x-stainless-runtime",
|
||
"x-stainless-runtime-version",
|
||
"x-stainless-retry-count",
|
||
"x-stainless-timeout",
|
||
"authorization",
|
||
"x-api-key",
|
||
"content-type",
|
||
"accept",
|
||
"x-stainless-helper-method",
|
||
}
|
||
|
||
h := make([]string, 0, len(interesting))
|
||
for _, k := range interesting {
|
||
if v := req.Header.Get(k); v != "" {
|
||
h = append(h, fmt.Sprintf("%s=%q", k, safeHeaderValueForLog(k, v)))
|
||
}
|
||
}
|
||
|
||
metaUserID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String())
|
||
sysPreview := strings.TrimSpace(extractSystemPreviewFromBody(body))
|
||
|
||
// Truncate preview to keep logs sane.
|
||
if len(sysPreview) > 300 {
|
||
sysPreview = sysPreview[:300] + "..."
|
||
}
|
||
sysPreview = strings.ReplaceAll(sysPreview, "\n", "\\n")
|
||
sysPreview = strings.ReplaceAll(sysPreview, "\r", "\\r")
|
||
|
||
aid := int64(0)
|
||
aname := ""
|
||
if account != nil {
|
||
aid = account.ID
|
||
aname = account.Name
|
||
}
|
||
|
||
return fmt.Sprintf(
|
||
"url=%s account=%d(%s) tokenType=%s mimic=%t meta.user_id=%q system.preview=%q headers={%s}",
|
||
req.URL.String(),
|
||
aid,
|
||
aname,
|
||
tokenType,
|
||
mimicClaudeCode,
|
||
metaUserID,
|
||
sysPreview,
|
||
strings.Join(h, " "),
|
||
)
|
||
}
|
||
|
||
func logClaudeMimicDebug(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) {
|
||
line := buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)
|
||
if line == "" {
|
||
return
|
||
}
|
||
logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebug] %s", line)
|
||
}
|
||
|
||
func isClaudeCodeCredentialScopeError(msg string) bool {
|
||
m := strings.ToLower(strings.TrimSpace(msg))
|
||
if m == "" {
|
||
return false
|
||
}
|
||
return strings.Contains(m, "only authorized for use with claude code") &&
|
||
strings.Contains(m, "cannot be used for other api requests")
|
||
}
|
||
|
||
// sseDataRe matches SSE data lines with optional whitespace after colon.
|
||
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
|
||
var (
|
||
sseDataRe = regexp.MustCompile(`^data:\s*`)
|
||
claudeCliUserAgentRe = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
|
||
|
||
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
|
||
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
|
||
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
|
||
claudeCodePromptPrefixes = []string{
|
||
"You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...)
|
||
"You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体
|
||
"You are a file search specialist for Claude Code", // Explore Agent 版
|
||
"You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
|
||
}
|
||
)
|
||
|
||
// ErrNoAvailableAccounts 表示没有可用的账号
|
||
var ErrNoAvailableAccounts = errors.New("no available accounts")
|
||
|
||
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
|
||
var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
|
||
|
||
// allowedHeaders 白名单headers(参考CRS项目)
|
||
var allowedHeaders = map[string]bool{
|
||
"accept": true,
|
||
"x-stainless-retry-count": true,
|
||
"x-stainless-timeout": true,
|
||
"x-stainless-lang": true,
|
||
"x-stainless-package-version": true,
|
||
"x-stainless-os": true,
|
||
"x-stainless-arch": true,
|
||
"x-stainless-runtime": true,
|
||
"x-stainless-runtime-version": true,
|
||
"x-stainless-helper-method": true,
|
||
"anthropic-dangerous-direct-browser-access": true,
|
||
"anthropic-version": true,
|
||
"x-app": true,
|
||
"anthropic-beta": true,
|
||
"accept-language": true,
|
||
"sec-fetch-mode": true,
|
||
"user-agent": true,
|
||
"content-type": true,
|
||
"accept-encoding": true,
|
||
"x-claude-code-session-id": true,
|
||
"x-client-request-id": true,
|
||
}
|
||
|
||
// ErrStickySessionNotFound is returned by GatewayCache.GetSessionAccountID
|
||
// when no binding exists for the session. It abstracts away the underlying
|
||
// cache implementation (e.g. redis.Nil), mirroring ErrRefreshTokenNotFound.
|
||
var ErrStickySessionNotFound = errors.New("sticky session not found")
|
||
|
||
// ErrReasoningContentNotFound is returned by GatewayCache.GetReasoningContent
|
||
// when no cached reasoning content exists for the reasoning item ID.
|
||
var ErrReasoningContentNotFound = errors.New("reasoning content not found")
|
||
|
||
// GatewayCache 定义网关服务的缓存操作接口。
|
||
// 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。
|
||
//
|
||
// GatewayCache defines cache operations for gateway service.
|
||
// Provides sticky session storage, retrieval, refresh and deletion capabilities.
|
||
type GatewayCache interface {
|
||
// GetSessionAccountID 获取粘性会话绑定的账号 ID;无绑定时返回
|
||
// ErrStickySessionNotFound,使 service 层无需依赖具体缓存实现即可
|
||
// 区分"未绑定"与真实读取失败。
|
||
// Get the account ID bound to a sticky session. Returns
|
||
// ErrStickySessionNotFound when no binding exists so service code can
|
||
// distinguish a miss from a real read failure without importing the
|
||
// cache driver.
|
||
GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error)
|
||
// SetSessionAccountID 设置粘性会话与账号的绑定关系
|
||
// Set the binding between sticky session and account
|
||
SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error
|
||
// RefreshSessionTTL 刷新粘性会话的过期时间
|
||
// Refresh the expiration time of a sticky session
|
||
RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error
|
||
// DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理
|
||
// Delete sticky session binding, used to proactively clean up when account becomes unavailable
|
||
DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error
|
||
|
||
// Grok async video billing snapshot (create → status success).
|
||
// SetGrokVideoPendingBilling stores create-time model/duration/resolution for status billing.
|
||
SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error
|
||
// GetGrokVideoPendingBilling returns the create-time billing snapshot; miss → nil, nil.
|
||
GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error)
|
||
// ClaimGrokVideoBilled atomically marks a video request as billed (SetNX).
|
||
// Returns true when this caller won the claim; false when already billed or claim unavailable.
|
||
ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||
// ReleaseGrokVideoBilled clears a claim so a failed RecordUsage can retry billing.
|
||
ReleaseGrokVideoBilled(ctx context.Context, key string) error
|
||
|
||
// Reasoning content cache (Responses→Chat Completions 桥接)。
|
||
// SetReasoningContent 按 reasoning item id 缓存 reasoning 全文,供后续请求
|
||
// 在客户端不回传明文 summary 时回注 reasoning_content(DeepSeek thinking
|
||
// mode 要求回传,否则 400)。
|
||
SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error
|
||
// GetReasoningContent 返回缓存的 reasoning 全文;未命中返回
|
||
// ErrReasoningContentNotFound,使 service 层无需依赖具体缓存实现即可
|
||
// 区分"未缓存"与真实读取失败。
|
||
GetReasoningContent(ctx context.Context, itemID string) (string, error)
|
||
}
|
||
|
||
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
|
||
func derefGroupID(groupID *int64) int64 {
|
||
if groupID == nil {
|
||
return 0
|
||
}
|
||
return *groupID
|
||
}
|
||
|
||
func resolveUserGroupRateCacheTTL(cfg *config.Config) time.Duration {
|
||
if cfg == nil || cfg.Gateway.UserGroupRateCacheTTLSeconds <= 0 {
|
||
return defaultUserGroupRateCacheTTL
|
||
}
|
||
return time.Duration(cfg.Gateway.UserGroupRateCacheTTLSeconds) * time.Second
|
||
}
|
||
|
||
func resolveModelsListCacheTTL(cfg *config.Config) time.Duration {
|
||
if cfg == nil || cfg.Gateway.ModelsListCacheTTLSeconds <= 0 {
|
||
return defaultModelsListCacheTTL
|
||
}
|
||
return time.Duration(cfg.Gateway.ModelsListCacheTTLSeconds) * time.Second
|
||
}
|
||
|
||
func modelsListCacheKey(groupID *int64, platform string) string {
|
||
return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform))
|
||
}
|
||
|
||
func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) {
|
||
return PrefetchedStickyGroupIDFromContext(ctx)
|
||
}
|
||
|
||
func prefetchedStickyAccountIDFromContext(ctx context.Context, groupID *int64) int64 {
|
||
prefetchedGroupID, ok := prefetchedStickyGroupIDFromContext(ctx)
|
||
if !ok || prefetchedGroupID != derefGroupID(groupID) {
|
||
return 0
|
||
}
|
||
if accountID, ok := PrefetchedStickyAccountIDFromContext(ctx); ok && accountID > 0 {
|
||
return accountID
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// shouldClearStickySession 检查账号是否处于不可调度状态,需要清理粘性会话绑定。
|
||
// 委托 IsSchedulable() 判断账号级可调度性(状态、配额、过载、限流等),
|
||
// 额外检查模型级限流。
|
||
//
|
||
// shouldClearStickySession checks if an account is in an unschedulable state
|
||
// and the sticky session binding should be cleared.
|
||
// Delegates to IsSchedulable() for account-level checks, plus model-level rate limiting.
|
||
func shouldClearStickySession(account *Account, requestedModel string) bool {
|
||
if account == nil {
|
||
return false
|
||
}
|
||
if !account.IsSchedulable() {
|
||
return true
|
||
}
|
||
if remaining := account.GetRateLimitRemainingTimeWithContext(context.Background(), requestedModel); remaining > 0 {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
type AccountWaitPlan struct {
|
||
AccountID int64
|
||
MaxConcurrency int
|
||
Timeout time.Duration
|
||
MaxWaiting int
|
||
}
|
||
|
||
type AccountSelectionResult struct {
|
||
Account *Account
|
||
Acquired bool
|
||
ReleaseFunc func()
|
||
WaitPlan *AccountWaitPlan // nil means no wait allowed
|
||
// profitGate 携带本次选号真实生效的利润门(无门为 nil)。门安装在调度栈的
|
||
// 局部 ctx 上,handler 必须经 ContextWithSelectionProfitGate 重放后才能在
|
||
// 调度栈之外做抢槽后终检与准入后粘性绑定。
|
||
profitGate *openAIProfitControlGate
|
||
}
|
||
|
||
// ProfitGateActive 报告本次选号是否处于利润门之下。
|
||
func (r *AccountSelectionResult) ProfitGateActive() bool {
|
||
return r != nil && r.profitGate != nil
|
||
}
|
||
|
||
// ClaudeUsage 表示Claude API返回的usage信息
|
||
type ClaudeUsage struct {
|
||
InputTokens int `json:"input_tokens"`
|
||
OutputTokens int `json:"output_tokens"`
|
||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||
CacheCreation5mTokens int // 5分钟缓存创建token(来自嵌套 cache_creation 对象)
|
||
CacheCreation1hTokens int // 1小时缓存创建token(来自嵌套 cache_creation 对象)
|
||
ImageOutputTokens int `json:"image_output_tokens,omitempty"`
|
||
}
|
||
|
||
// ForwardResult 转发结果
|
||
type AudioUsage struct {
|
||
Mode string // realtime | tts | stt
|
||
DurationOrUnits float64 // minutes / million-chars / hours
|
||
}
|
||
|
||
type ForwardResult struct {
|
||
RequestID string
|
||
Usage ClaudeUsage
|
||
Model string
|
||
// UpstreamModel is the actual upstream model after mapping.
|
||
// Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings.
|
||
UpstreamModel string
|
||
// UpstreamResponseModel is captured from the raw successful upstream
|
||
// response before any client-facing rewrite or protocol conversion.
|
||
UpstreamResponseModel string
|
||
UpstreamResponseModelConflict bool
|
||
Stream bool
|
||
Duration time.Duration
|
||
FirstTokenMs *int // 首字时间(流式请求)
|
||
ClientDisconnect bool // 客户端是否在流式传输过程中断开
|
||
ReasoningEffort *string
|
||
// ServiceTier records the billable request tier. OpenAI uses service_tier;
|
||
// Anthropic speed=fast is normalized to "fast".
|
||
ServiceTier *string
|
||
|
||
// 图片生成计费字段(图片生成模型使用)
|
||
ImageCount int // 生成的图片数量
|
||
ImageSize string // 最终计费尺寸 "1K", "2K", "4K"
|
||
ImageInputSize string // 请求中的原始图片尺寸
|
||
ImageOutputSize string // 上游响应中的图片尺寸
|
||
ImageOutputSizes []string
|
||
ImageSizeSource string
|
||
ImageSizeBreakdown map[string]int
|
||
SearchCount int
|
||
AudioUsage *AudioUsage
|
||
}
|
||
|
||
// GatewayFailureStage identifies which request stage failed. The zero value is
|
||
// intentionally treated as inference so existing UpstreamFailoverError callers
|
||
// retain their current behavior.
|
||
type GatewayFailureStage string
|
||
|
||
const (
|
||
GatewayFailureStageInference GatewayFailureStage = "inference"
|
||
GatewayFailureStageAccountAuth GatewayFailureStage = "account_auth"
|
||
)
|
||
|
||
// GatewayFailureScope identifies whether selecting another account can help.
|
||
type GatewayFailureScope string
|
||
|
||
const (
|
||
GatewayFailureScopeAccount GatewayFailureScope = "account"
|
||
GatewayFailureScopeProvider GatewayFailureScope = "provider"
|
||
GatewayFailureScopeRequest GatewayFailureScope = "request"
|
||
)
|
||
|
||
// NextAccountAction is tri-state for backwards compatibility. The zero value
|
||
// means legacy retry behavior; only NextAccountStop explicitly short-circuits.
|
||
type NextAccountAction uint8
|
||
|
||
const (
|
||
NextAccountLegacyRetry NextAccountAction = iota
|
||
NextAccountRetry
|
||
NextAccountStop
|
||
)
|
||
|
||
type GatewayFailureReason string
|
||
|
||
// UpstreamFailoverError indicates an upstream or credential error that may
|
||
// trigger account failover. Additive metadata keeps existing composite literals
|
||
// source-compatible and preserves their legacy retry-next-account behavior.
|
||
type UpstreamFailoverError struct {
|
||
StatusCode int
|
||
ResponseBody []byte // 上游响应体,用于错误透传规则匹配
|
||
ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息
|
||
ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true
|
||
RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换
|
||
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
|
||
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
|
||
Stage GatewayFailureStage
|
||
Scope GatewayFailureScope
|
||
Reason GatewayFailureReason
|
||
NextAccountAction NextAccountAction
|
||
ClientStatusCode int
|
||
ClientMessage string
|
||
}
|
||
|
||
func (e *UpstreamFailoverError) Error() string {
|
||
if e != nil && e.Stage == GatewayFailureStageAccountAuth {
|
||
return fmt.Sprintf("credential failure: %s (failover)", e.Reason)
|
||
}
|
||
return fmt.Sprintf("upstream error: %d (failover)", e.StatusCode)
|
||
}
|
||
|
||
func (e *UpstreamFailoverError) ShouldRetryNextAccount() bool {
|
||
return e != nil && e.NextAccountAction != NextAccountStop
|
||
}
|
||
|
||
func (e *UpstreamFailoverError) IsCredentialFailure() bool {
|
||
return e != nil && e.Stage == GatewayFailureStageAccountAuth
|
||
}
|
||
|
||
// ShouldReportAccountScheduleFailure prevents provider- and request-scoped
|
||
// credential failures from being misattributed to the selected account. Legacy
|
||
// and inference failures retain their existing scheduler-health behavior.
|
||
func (e *UpstreamFailoverError) ShouldReportAccountScheduleFailure() bool {
|
||
if e == nil {
|
||
return false
|
||
}
|
||
return !e.IsCredentialFailure() || e.Scope == GatewayFailureScopeAccount
|
||
}
|
||
|
||
// sseStreamErrorEventError 表示上游 SSE 流体内出现 event:error 帧。
|
||
// RawData 是该事件 data: 行的原始 JSON 字符串
|
||
// (Anthropic 标准结构 {"type":"error","error":{"type":"...","message":"..."}})。
|
||
// Error() 保持原字符串以兼容现有日志/检索;调用方应通过 errors.As
|
||
// 提取 RawData 并构造 UpstreamFailoverError.ResponseBody。
|
||
type sseStreamErrorEventError struct {
|
||
RawData string
|
||
}
|
||
|
||
func (e *sseStreamErrorEventError) Error() string { return "have error in stream" }
|
||
|
||
// TempUnscheduleRetryableError 对 RetryableOnSameAccount 类型的 failover 错误触发临时封禁。
|
||
// 由 handler 层在同账号重试全部用尽、切换账号时调用。
|
||
func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accountID int64, failoverErr *UpstreamFailoverError) {
|
||
if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
|
||
return
|
||
}
|
||
// 请求级瞬时故障与账号健康无关:封禁只会把与故障无关的账号一并摘掉,
|
||
// 而故障因素(客户端身份、模型容量)在下一个账号上完全相同。
|
||
if failoverErr.RequestScopedTransient {
|
||
return
|
||
}
|
||
// 根据状态码选择封禁策略
|
||
switch failoverErr.StatusCode {
|
||
case http.StatusBadRequest:
|
||
tempUnscheduleGoogleConfigError(ctx, s.accountRepo, accountID, "[handler]")
|
||
case http.StatusBadGateway:
|
||
tempUnscheduleEmptyResponse(ctx, s.accountRepo, accountID, "[handler]")
|
||
}
|
||
}
|
||
|
||
// GatewayService handles API gateway operations
|
||
type GatewayService struct {
|
||
accountRepo AccountRepository
|
||
groupRepo GroupRepository
|
||
usageLogRepo UsageLogRepository
|
||
usageBillingRepo UsageBillingRepository
|
||
userRepo UserRepository
|
||
userSubRepo UserSubscriptionRepository
|
||
userGroupRateRepo UserGroupRateRepository
|
||
cache GatewayCache
|
||
digestStore *DigestSessionStore
|
||
cfg *config.Config
|
||
schedulerSnapshot *SchedulerSnapshotService
|
||
billingService *BillingService
|
||
rateLimitService *RateLimitService
|
||
billingCacheService *BillingCacheService
|
||
identityService *IdentityService
|
||
httpUpstream HTTPUpstream
|
||
deferredService *DeferredService
|
||
concurrencyService *ConcurrencyService
|
||
claudeTokenProvider *ClaudeTokenProvider
|
||
sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
|
||
rpmCache RPMCache // RPM 计数缓存(仅 Anthropic OAuth/SetupToken)
|
||
userGroupRateResolver *userGroupRateResolver
|
||
userGroupRateCache *gocache.Cache
|
||
userGroupRateSF singleflight.Group
|
||
modelsListCache *gocache.Cache
|
||
modelsListCacheTTL time.Duration
|
||
settingService *SettingService
|
||
responseHeaderFilter *responseheaders.CompiledHeaderFilter
|
||
debugModelRouting atomic.Bool
|
||
debugClaudeMimic atomic.Bool
|
||
channelService *ChannelService
|
||
resolver *ModelPricingResolver
|
||
compositeResolver *CompositeRouteResolver
|
||
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
|
||
tlsFPProfileService *TLSFingerprintProfileService
|
||
balanceNotifyService *BalanceNotifyService
|
||
userPlatformQuotaRepo UserPlatformQuotaRepository
|
||
}
|
||
|
||
// NewGatewayService creates a new GatewayService
|
||
func NewGatewayService(
|
||
accountRepo AccountRepository,
|
||
groupRepo GroupRepository,
|
||
usageLogRepo UsageLogRepository,
|
||
usageBillingRepo UsageBillingRepository,
|
||
userRepo UserRepository,
|
||
userSubRepo UserSubscriptionRepository,
|
||
userGroupRateRepo UserGroupRateRepository,
|
||
cache GatewayCache,
|
||
cfg *config.Config,
|
||
schedulerSnapshot *SchedulerSnapshotService,
|
||
concurrencyService *ConcurrencyService,
|
||
billingService *BillingService,
|
||
rateLimitService *RateLimitService,
|
||
billingCacheService *BillingCacheService,
|
||
identityService *IdentityService,
|
||
httpUpstream HTTPUpstream,
|
||
deferredService *DeferredService,
|
||
claudeTokenProvider *ClaudeTokenProvider,
|
||
sessionLimitCache SessionLimitCache,
|
||
rpmCache RPMCache,
|
||
digestStore *DigestSessionStore,
|
||
settingService *SettingService,
|
||
tlsFPProfileService *TLSFingerprintProfileService,
|
||
channelService *ChannelService,
|
||
resolver *ModelPricingResolver,
|
||
compositeResolver *CompositeRouteResolver,
|
||
balanceNotifyService *BalanceNotifyService,
|
||
userPlatformQuotaRepo UserPlatformQuotaRepository,
|
||
) *GatewayService {
|
||
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
|
||
modelsListTTL := resolveModelsListCacheTTL(cfg)
|
||
|
||
svc := &GatewayService{
|
||
accountRepo: accountRepo,
|
||
groupRepo: groupRepo,
|
||
usageLogRepo: usageLogRepo,
|
||
usageBillingRepo: usageBillingRepo,
|
||
userRepo: userRepo,
|
||
userSubRepo: userSubRepo,
|
||
userGroupRateRepo: userGroupRateRepo,
|
||
cache: cache,
|
||
digestStore: digestStore,
|
||
cfg: cfg,
|
||
schedulerSnapshot: schedulerSnapshot,
|
||
concurrencyService: concurrencyService,
|
||
billingService: billingService,
|
||
rateLimitService: rateLimitService,
|
||
billingCacheService: billingCacheService,
|
||
identityService: identityService,
|
||
httpUpstream: httpUpstream,
|
||
deferredService: deferredService,
|
||
claudeTokenProvider: claudeTokenProvider,
|
||
sessionLimitCache: sessionLimitCache,
|
||
rpmCache: rpmCache,
|
||
userGroupRateCache: gocache.New(userGroupRateTTL, time.Minute),
|
||
settingService: settingService,
|
||
modelsListCache: gocache.New(modelsListTTL, time.Minute),
|
||
modelsListCacheTTL: modelsListTTL,
|
||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||
tlsFPProfileService: tlsFPProfileService,
|
||
channelService: channelService,
|
||
resolver: resolver,
|
||
compositeResolver: compositeResolver,
|
||
balanceNotifyService: balanceNotifyService,
|
||
userPlatformQuotaRepo: userPlatformQuotaRepo,
|
||
}
|
||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||
userGroupRateRepo,
|
||
svc.userGroupRateCache,
|
||
userGroupRateTTL,
|
||
&svc.userGroupRateSF,
|
||
"service.gateway",
|
||
)
|
||
svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
|
||
svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
|
||
if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" {
|
||
svc.initDebugGatewayBodyFile(path)
|
||
}
|
||
return svc
|
||
}
|
||
|
||
// GenerateSessionHash 从预解析请求计算粘性会话 hash
|
||
func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
|
||
if parsed == nil {
|
||
return ""
|
||
}
|
||
|
||
// 1. 最高优先级:从 metadata.user_id 提取 session_xxx
|
||
if parsed.MetadataUserID != "" {
|
||
uid := ParseMetadataUserID(parsed.MetadataUserID)
|
||
if uid != nil && uid.SessionID != "" {
|
||
slog.Info("sticky.hash_source",
|
||
"source", "metadata_user_id",
|
||
"session_id", uid.SessionID,
|
||
"device_id", uid.DeviceID,
|
||
"is_new_format", uid.IsNewFormat,
|
||
)
|
||
return uid.SessionID
|
||
}
|
||
slog.Info("sticky.hash_metadata_parse_failed",
|
||
"metadata_user_id", parsed.MetadataUserID,
|
||
"parsed_nil", uid == nil,
|
||
)
|
||
}
|
||
|
||
// 2. 提取带 cache_control: {type: "ephemeral"} 的内容
|
||
cacheableContent := s.extractCacheableContent(parsed)
|
||
if cacheableContent != "" {
|
||
hash := s.hashContent(cacheableContent)
|
||
slog.Info("sticky.hash_source",
|
||
"source", "cacheable_content",
|
||
"hash", hash,
|
||
)
|
||
return hash
|
||
}
|
||
|
||
// 3. 最后 fallback: 使用 session上下文 + system + 所有消息的完整摘要串
|
||
var combined strings.Builder
|
||
// 混入请求上下文区分因子,避免不同用户相同消息产生相同 hash
|
||
if parsed.SessionContext != nil {
|
||
_, _ = combined.WriteString(parsed.SessionContext.ClientIP)
|
||
_, _ = combined.WriteString(":")
|
||
_, _ = combined.WriteString(NormalizeSessionUserAgent(parsed.SessionContext.UserAgent))
|
||
_, _ = combined.WriteString(":")
|
||
_, _ = combined.WriteString(strconv.FormatInt(parsed.SessionContext.APIKeyID, 10))
|
||
_, _ = combined.WriteString("|")
|
||
}
|
||
if systemText := extractTextFromSystemRaw(parsed.SystemRaw()); systemText != "" {
|
||
_, _ = combined.WriteString(systemText)
|
||
}
|
||
contentStart := combined.Len()
|
||
appendMessageTextsFromRaw(&combined, parsed.MessagesRaw())
|
||
if combined.Len() == contentStart {
|
||
appendResponsesSessionAnchorFromRaw(&combined, parsed.InputRaw())
|
||
}
|
||
if combined.Len() > 0 {
|
||
hash := s.hashContent(combined.String())
|
||
slog.Info("sticky.hash_source",
|
||
"source", "message_content_fallback",
|
||
"hash", hash,
|
||
"content_len", combined.Len(),
|
||
)
|
||
return hash
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// BindStickySession sets session -> account binding with standard TTL.
|
||
func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
|
||
if sessionHash == "" || accountID <= 0 || s.cache == nil {
|
||
return nil
|
||
}
|
||
return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL)
|
||
}
|
||
|
||
// bindGatewayStickySessionDuringSelection preserves the normal eager sticky
|
||
// behavior unless a profit gate is installed. Profit-controlled requests bind
|
||
// only after the terminal post-slot check, otherwise a rejected candidate could
|
||
// overwrite a healthy pre-existing sticky binding.
|
||
func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
|
||
if gatewayProfitControlGateActive(ctx) {
|
||
return nil
|
||
}
|
||
return s.BindStickySession(ctx, groupID, sessionHash, accountID)
|
||
}
|
||
|
||
// BindStickySessionAfterProfitAdmission records a terminally admitted
|
||
// account. Without a profit gate it preserves the pre-existing eager binding
|
||
// behavior at the handler bind points. With a gate it never replaces a
|
||
// different binding that already exists: a temporarily ineligible sticky
|
||
// account remains bound and automatically becomes eligible again if its
|
||
// account rate recovers.
|
||
func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
|
||
if sessionHash == "" || accountID <= 0 || s.cache == nil {
|
||
return nil
|
||
}
|
||
if !gatewayProfitControlGateActive(ctx) {
|
||
return s.BindStickySession(ctx, groupID, sessionHash, accountID)
|
||
}
|
||
existingAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
||
if err != nil && !errors.Is(err, ErrStickySessionNotFound) {
|
||
// 读失败时无法判断既有绑定,保守跳过而不是冒着覆盖健康绑定的风险写入。
|
||
slog.Warn("profit_control_sticky_binding_read_failed", "group_id", derefGroupID(groupID), "account_id", accountID, "error", err)
|
||
return nil
|
||
}
|
||
if existingAccountID > 0 && existingAccountID != accountID {
|
||
return nil
|
||
}
|
||
return s.BindStickySession(ctx, groupID, sessionHash, accountID)
|
||
}
|
||
|
||
// GetCachedSessionAccountID retrieves the account ID bound to a sticky session.
|
||
// Returns 0 if no binding exists or on error.
|
||
func (s *GatewayService) GetCachedSessionAccountID(ctx context.Context, groupID *int64, sessionHash string) (int64, error) {
|
||
if sessionHash == "" || s.cache == nil {
|
||
return 0, nil
|
||
}
|
||
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return accountID, nil
|
||
}
|
||
|
||
// FindGeminiSession 查找 Gemini 会话(基于内容摘要链的 Fallback 匹配)
|
||
// 返回最长匹配的会话信息(uuid, accountID)
|
||
func (s *GatewayService) FindGeminiSession(_ context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, matchedChain string, found bool) {
|
||
if digestChain == "" || s.digestStore == nil {
|
||
return "", 0, "", false
|
||
}
|
||
return s.digestStore.Find(groupID, prefixHash, digestChain)
|
||
}
|
||
|
||
// SaveGeminiSession 保存 Gemini 会话。oldDigestChain 为 Find 返回的 matchedChain,用于删旧 key。
|
||
func (s *GatewayService) SaveGeminiSession(_ context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64, oldDigestChain string) error {
|
||
if digestChain == "" || s.digestStore == nil {
|
||
return nil
|
||
}
|
||
s.digestStore.Save(groupID, prefixHash, digestChain, uuid, accountID, oldDigestChain)
|
||
return nil
|
||
}
|
||
|
||
// FindAnthropicSession 查找 Anthropic 会话(基于内容摘要链的 Fallback 匹配)
|
||
func (s *GatewayService) FindAnthropicSession(_ context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, matchedChain string, found bool) {
|
||
if digestChain == "" || s.digestStore == nil {
|
||
return "", 0, "", false
|
||
}
|
||
return s.digestStore.Find(groupID, prefixHash, digestChain)
|
||
}
|
||
|
||
// SaveAnthropicSession 保存 Anthropic 会话
|
||
func (s *GatewayService) SaveAnthropicSession(_ context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64, oldDigestChain string) error {
|
||
if digestChain == "" || s.digestStore == nil {
|
||
return nil
|
||
}
|
||
s.digestStore.Save(groupID, prefixHash, digestChain, uuid, accountID, oldDigestChain)
|
||
return nil
|
||
}
|
||
|
||
func (s *GatewayService) extractCacheableContent(parsed *ParsedRequest) string {
|
||
if parsed == nil {
|
||
return ""
|
||
}
|
||
|
||
systemText := extractCacheableTextFromSystemRaw(parsed.SystemRaw())
|
||
if messageText := extractCacheableTextFromMessagesRaw(parsed.MessagesRaw()); messageText != "" {
|
||
return messageText
|
||
}
|
||
return systemText
|
||
}
|
||
|
||
func parseRawJSONView(raw []byte) gjson.Result {
|
||
if len(raw) == 0 {
|
||
return gjson.Result{}
|
||
}
|
||
// 这里只做同步只读解析,避免 gjson.ParseBytes 为大 messages/contents 复制整段 raw。
|
||
return gjson.Parse(*(*string)(unsafe.Pointer(&raw)))
|
||
}
|
||
|
||
func extractTextFromSystemRaw(raw []byte) string {
|
||
system := parseRawJSONView(raw)
|
||
switch system.Type {
|
||
case gjson.String:
|
||
return system.String()
|
||
case gjson.JSON:
|
||
if !system.IsArray() {
|
||
return ""
|
||
}
|
||
var builder strings.Builder
|
||
system.ForEach(func(_, part gjson.Result) bool {
|
||
if text := part.Get("text").String(); text != "" {
|
||
_, _ = builder.WriteString(text)
|
||
}
|
||
return true
|
||
})
|
||
return builder.String()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractTextFromContentRaw(content gjson.Result) string {
|
||
switch content.Type {
|
||
case gjson.String:
|
||
return content.String()
|
||
case gjson.JSON:
|
||
if !content.IsArray() {
|
||
return ""
|
||
}
|
||
var builder strings.Builder
|
||
content.ForEach(func(_, part gjson.Result) bool {
|
||
if part.Get("type").String() == "text" {
|
||
if text := part.Get("text").String(); text != "" {
|
||
_, _ = builder.WriteString(text)
|
||
}
|
||
}
|
||
return true
|
||
})
|
||
return builder.String()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func appendMessageTextsFromRaw(builder *strings.Builder, raw []byte) {
|
||
if builder == nil || len(raw) == 0 {
|
||
return
|
||
}
|
||
messages := parseRawJSONView(raw)
|
||
if !messages.IsArray() {
|
||
return
|
||
}
|
||
messages.ForEach(func(_, msg gjson.Result) bool {
|
||
if content := msg.Get("content"); content.Exists() {
|
||
_, _ = builder.WriteString(extractTextFromContentRaw(content))
|
||
return true
|
||
}
|
||
parts := msg.Get("parts")
|
||
if parts.IsArray() {
|
||
parts.ForEach(func(_, part gjson.Result) bool {
|
||
if text := part.Get("text").String(); text != "" {
|
||
_, _ = builder.WriteString(text)
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
func appendResponsesSessionAnchorFromRaw(builder *strings.Builder, raw []byte) {
|
||
if builder == nil || len(raw) == 0 {
|
||
return
|
||
}
|
||
input := parseRawJSONView(raw)
|
||
if input.Type == gjson.String {
|
||
_, _ = builder.WriteString(input.String())
|
||
return
|
||
}
|
||
if !input.IsArray() {
|
||
return
|
||
}
|
||
|
||
input.ForEach(func(_, item gjson.Result) bool {
|
||
if item.Type == gjson.String {
|
||
_, _ = builder.WriteString(item.String())
|
||
return false
|
||
}
|
||
|
||
switch item.Get("role").String() {
|
||
case "system", "developer":
|
||
appendResponsesContentText(builder, item.Get("content"))
|
||
case "user":
|
||
appendResponsesContentText(builder, item.Get("content"))
|
||
return false
|
||
default:
|
||
if item.Get("type").String() == "input_text" {
|
||
if text := item.Get("text").String(); text != "" {
|
||
_, _ = builder.WriteString(text)
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
func appendResponsesContentText(builder *strings.Builder, content gjson.Result) {
|
||
if builder == nil || !content.Exists() {
|
||
return
|
||
}
|
||
if content.Type == gjson.String {
|
||
_, _ = builder.WriteString(content.String())
|
||
return
|
||
}
|
||
if !content.IsArray() {
|
||
return
|
||
}
|
||
content.ForEach(func(_, part gjson.Result) bool {
|
||
switch part.Get("type").String() {
|
||
case "input_text", "text":
|
||
if text := part.Get("text").String(); text != "" {
|
||
_, _ = builder.WriteString(text)
|
||
}
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
func extractCacheableTextFromSystemRaw(raw []byte) string {
|
||
system := parseRawJSONView(raw)
|
||
if !system.IsArray() {
|
||
return ""
|
||
}
|
||
var builder strings.Builder
|
||
system.ForEach(func(_, part gjson.Result) bool {
|
||
if part.Get("cache_control.type").String() == "ephemeral" {
|
||
if text := part.Get("text").String(); text != "" {
|
||
_, _ = builder.WriteString(text)
|
||
}
|
||
}
|
||
return true
|
||
})
|
||
return builder.String()
|
||
}
|
||
|
||
func extractCacheableTextFromMessagesRaw(raw []byte) string {
|
||
messages := parseRawJSONView(raw)
|
||
if !messages.IsArray() {
|
||
return ""
|
||
}
|
||
var text string
|
||
messages.ForEach(func(_, msg gjson.Result) bool {
|
||
content := msg.Get("content")
|
||
if !content.IsArray() {
|
||
return true
|
||
}
|
||
found := false
|
||
content.ForEach(func(_, part gjson.Result) bool {
|
||
if part.Get("cache_control.type").String() == "ephemeral" {
|
||
found = true
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
if found {
|
||
text = extractTextFromContentRaw(content)
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
return text
|
||
}
|
||
|
||
func (s *GatewayService) hashContent(content string) string {
|
||
h := xxhash.Sum64String(content)
|
||
return strconv.FormatUint(h, 36)
|
||
}
|
||
|
||
// GetAccessToken 获取账号凭证
|
||
func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
|
||
switch account.Type {
|
||
case AccountTypeOAuth, AccountTypeSetupToken:
|
||
// Both oauth and setup-token use OAuth token flow
|
||
return s.getOAuthToken(ctx, account)
|
||
case AccountTypeAPIKey:
|
||
apiKey := account.GetCredential("api_key")
|
||
if apiKey == "" {
|
||
return "", "", errors.New("api_key not found in credentials")
|
||
}
|
||
return apiKey, "apikey", nil
|
||
case AccountTypeBedrock:
|
||
return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
|
||
case AccountTypeServiceAccount:
|
||
if account.Platform != PlatformAnthropic {
|
||
return "", "", fmt.Errorf("unsupported service account platform: %s", account.Platform)
|
||
}
|
||
if s.claudeTokenProvider == nil {
|
||
return "", "", errors.New("claude token provider not configured")
|
||
}
|
||
accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
return accessToken, "service_account", nil
|
||
default:
|
||
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
||
}
|
||
}
|
||
|
||
func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (string, string, error) {
|
||
// 对于 Anthropic OAuth 账号,使用 ClaudeTokenProvider 获取缓存的 token
|
||
if account.Platform == PlatformAnthropic && account.Type == AccountTypeOAuth && s.claudeTokenProvider != nil {
|
||
accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
return accessToken, "oauth", nil
|
||
}
|
||
|
||
// Grok OAuth: prefer access_token from credentials (background refresher keeps it warm).
|
||
if account.Platform == PlatformGrok && account.Type == AccountTypeOAuth {
|
||
accessToken := account.GetGrokAccessToken()
|
||
if accessToken == "" {
|
||
return "", "", errors.New("grok access_token not found in credentials")
|
||
}
|
||
return accessToken, "oauth", nil
|
||
}
|
||
|
||
// 其他情况(Gemini 有自己的 TokenProvider,setup-token 类型等)直接从账号读取
|
||
accessToken := account.GetCredential("access_token")
|
||
if accessToken == "" {
|
||
return "", "", errors.New("access_token not found in credentials")
|
||
}
|
||
// Token刷新由后台 TokenRefreshService 处理,此处只返回当前token
|
||
return accessToken, "oauth", nil
|
||
}
|
||
|
||
// GetAvailableModels returns the list of models available for a group
|
||
// It aggregates model_mapping keys from all schedulable accounts in the group
|
||
|
||
// DoGrokNativeResponsesJSON POSTs a non-streaming Responses body to the account's
|
||
// Grok upstream and returns the raw JSON body. Used by /v1/web_search.
|
||
// Gin-free: UA is always the pinned Grok CLI identity (resolveGrokUpstreamUserAgent ignores inbound).
|
||
func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, account *Account, body []byte) ([]byte, error) {
|
||
if s == nil || s.httpUpstream == nil {
|
||
return nil, errors.New("http upstream not configured")
|
||
}
|
||
if account == nil {
|
||
return nil, errors.New("account is required")
|
||
}
|
||
if !account.IsGrok() {
|
||
return nil, errors.New("grok account required")
|
||
}
|
||
token, _, err := s.GetAccessToken(ctx, account)
|
||
if err != nil {
|
||
// Credential/token failures should try the next Grok account in the pool.
|
||
return nil, &UpstreamFailoverError{
|
||
StatusCode: http.StatusUnauthorized,
|
||
Reason: GatewayFailureReason("grok_search_token"),
|
||
}
|
||
}
|
||
targetURL, err := buildGrokResponsesURL(account, nil, s.settingService)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if json.Valid(body) {
|
||
if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model == "" {
|
||
if patched, patchErr := sjson.SetBytes(body, "model", xai.DefaultTextModel); patchErr == nil {
|
||
body = patched
|
||
}
|
||
}
|
||
}
|
||
upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("build grok responses request: %w", err)
|
||
}
|
||
upstreamReq.Header.Set("Authorization", "Bearer "+token)
|
||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||
upstreamReq.Header.Set("Accept", "application/json")
|
||
upstreamReq.Header.Set("User-Agent", defaultGrokUpstreamUserAgent())
|
||
applyGrokCLIHeaders(upstreamReq.Header)
|
||
account.ApplyHeaderOverrides(upstreamReq.Header)
|
||
proxyURL := ""
|
||
if account.ProxyID != nil && account.Proxy != nil {
|
||
proxyURL = account.Proxy.URL()
|
||
}
|
||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||
if err != nil {
|
||
return nil, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, Reason: GatewayFailureReason("grok_search_transport")}
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
respBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if readErr != nil {
|
||
return nil, &UpstreamFailoverError{
|
||
StatusCode: http.StatusBadGateway,
|
||
Reason: GatewayFailureReason("grok_search_read"),
|
||
}
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
msg := string(respBytes)
|
||
if len(msg) > 200 {
|
||
msg = msg[:200]
|
||
}
|
||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPaymentRequired || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBytes}
|
||
}
|
||
return nil, fmt.Errorf("grok upstream %d: %s", resp.StatusCode, msg)
|
||
}
|
||
return respBytes, nil
|
||
}
|
||
|
||
func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string {
|
||
cacheKey := modelsListCacheKey(groupID, platform)
|
||
if s.modelsListCache != nil {
|
||
if cached, found := s.modelsListCache.Get(cacheKey); found {
|
||
if models, ok := cached.([]string); ok {
|
||
modelsListCacheHitTotal.Add(1)
|
||
return cloneStringSlice(models)
|
||
}
|
||
}
|
||
}
|
||
modelsListCacheMissTotal.Add(1)
|
||
|
||
var accounts []Account
|
||
var err error
|
||
|
||
if groupID != nil {
|
||
accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID)
|
||
} else {
|
||
accounts, err = s.accountRepo.ListSchedulable(ctx)
|
||
}
|
||
|
||
if err != nil || len(accounts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Filter by platform if specified
|
||
if platform != "" {
|
||
filtered := make([]Account, 0)
|
||
for _, acc := range accounts {
|
||
if acc.Platform == platform {
|
||
filtered = append(filtered, acc)
|
||
}
|
||
}
|
||
accounts = filtered
|
||
}
|
||
|
||
// Collect unique models from all accounts
|
||
modelSet := make(map[string]struct{})
|
||
hasAnyMapping := false
|
||
|
||
for _, acc := range accounts {
|
||
// Passthrough routing accepts models independently of model_mapping. A stale
|
||
// mapping on any eligible passthrough account therefore cannot define the
|
||
// public whitelist; return nil so the handler uses its default model set.
|
||
if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() {
|
||
if s.modelsListCache != nil {
|
||
s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
|
||
modelsListCacheStoreTotal.Add(1)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
mapping := acc.GetModelMapping()
|
||
if len(mapping) > 0 {
|
||
hasAnyMapping = true
|
||
for model := range mapping {
|
||
modelSet[model] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If no account has model_mapping, return nil (use default)
|
||
if !hasAnyMapping {
|
||
if s.modelsListCache != nil {
|
||
s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
|
||
modelsListCacheStoreTotal.Add(1)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Convert to slice
|
||
models := make([]string, 0, len(modelSet))
|
||
for model := range modelSet {
|
||
models = append(models, model)
|
||
}
|
||
sort.Strings(models)
|
||
|
||
if s.modelsListCache != nil {
|
||
s.modelsListCache.Set(cacheKey, cloneStringSlice(models), s.modelsListCacheTTL)
|
||
modelsListCacheStoreTotal.Add(1)
|
||
}
|
||
return cloneStringSlice(models)
|
||
}
|
||
|
||
// GetSchedulablePlatforms returns the concrete platforms that currently have
|
||
// schedulable accounts in the target group.
|
||
func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} {
|
||
platforms := make(map[string]struct{})
|
||
if s == nil || s.accountRepo == nil {
|
||
return platforms
|
||
}
|
||
|
||
var accounts []Account
|
||
var err error
|
||
if groupID != nil {
|
||
accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID)
|
||
} else {
|
||
accounts, err = s.accountRepo.ListSchedulable(ctx)
|
||
}
|
||
if err != nil {
|
||
return platforms
|
||
}
|
||
|
||
for _, acc := range accounts {
|
||
platform := strings.TrimSpace(acc.Platform)
|
||
if platform != "" {
|
||
platforms[platform] = struct{}{}
|
||
}
|
||
}
|
||
return platforms
|
||
}
|
||
|
||
func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform string) {
|
||
if s == nil || s.modelsListCache == nil {
|
||
return
|
||
}
|
||
|
||
normalizedPlatform := strings.TrimSpace(platform)
|
||
// 完整匹配时精准失效;否则按维度批量失效。
|
||
if groupID != nil && normalizedPlatform != "" {
|
||
s.modelsListCache.Delete(modelsListCacheKey(groupID, normalizedPlatform))
|
||
return
|
||
}
|
||
|
||
targetGroup := derefGroupID(groupID)
|
||
for key := range s.modelsListCache.Items() {
|
||
parts := strings.SplitN(key, "|", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
groupPart, parseErr := strconv.ParseInt(parts[0], 10, 64)
|
||
if parseErr != nil {
|
||
continue
|
||
}
|
||
if groupID != nil && groupPart != targetGroup {
|
||
continue
|
||
}
|
||
if normalizedPlatform != "" && parts[1] != normalizedPlatform {
|
||
continue
|
||
}
|
||
s.modelsListCache.Delete(key)
|
||
}
|
||
}
|
||
|
||
const debugGatewayBodyDefaultFilename = "gateway_debug.log"
|
||
|
||
// initDebugGatewayBodyFile 初始化网关调试日志文件。
|
||
//
|
||
// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
|
||
// - 已有目录路径 → 该目录下 gateway_debug.log
|
||
// - 其他 → 视为完整文件路径
|
||
func (s *GatewayService) initDebugGatewayBodyFile(path string) {
|
||
if parseDebugEnvBool(path) {
|
||
path = debugGatewayBodyDefaultFilename
|
||
}
|
||
|
||
// 如果 path 指向一个已存在的目录,自动追加默认文件名
|
||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||
path = filepath.Join(path, debugGatewayBodyDefaultFilename)
|
||
}
|
||
|
||
// 确保父目录存在
|
||
if dir := filepath.Dir(path); dir != "." {
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err)
|
||
return
|
||
}
|
||
}
|
||
|
||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||
if err != nil {
|
||
slog.Error("failed to open gateway debug log file", "path", path, "error", err)
|
||
return
|
||
}
|
||
s.debugGatewayBodyFile.Store(f)
|
||
slog.Info("gateway debug logging enabled", "path", path)
|
||
}
|
||
|
||
// debugLogGatewaySnapshot 将网关请求的完整快照(headers + body)写入独立的调试日志文件,
|
||
// 用于对比客户端原始请求和上游转发请求。
|
||
//
|
||
// 启用方式(环境变量):
|
||
//
|
||
// SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log
|
||
// SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径
|
||
//
|
||
// tag: "CLIENT_ORIGINAL" 或 "UPSTREAM_FORWARD"
|
||
func (s *GatewayService) debugLogGatewaySnapshot(tag string, headers http.Header, body []byte, extra map[string]string) {
|
||
f := s.debugGatewayBodyFile.Load()
|
||
if f == nil {
|
||
return
|
||
}
|
||
|
||
var buf strings.Builder
|
||
ts := time.Now().Format("2006-01-02 15:04:05.000")
|
||
fmt.Fprintf(&buf, "\n========== [%s] %s ==========\n", ts, tag)
|
||
|
||
// 1. context
|
||
if len(extra) > 0 {
|
||
fmt.Fprint(&buf, "--- context ---\n")
|
||
extraKeys := make([]string, 0, len(extra))
|
||
for k := range extra {
|
||
extraKeys = append(extraKeys, k)
|
||
}
|
||
sort.Strings(extraKeys)
|
||
for _, k := range extraKeys {
|
||
fmt.Fprintf(&buf, " %s: %s\n", k, extra[k])
|
||
}
|
||
}
|
||
|
||
// 2. headers(按真实 Claude CLI wire 顺序排列,便于与抓包对比;auth 脱敏)
|
||
fmt.Fprint(&buf, "--- headers ---\n")
|
||
for _, k := range sortHeadersByWireOrder(headers) {
|
||
for _, v := range headers[k] {
|
||
fmt.Fprintf(&buf, " %s: %s\n", k, safeHeaderValueForLog(k, v))
|
||
}
|
||
}
|
||
|
||
// 3. body(完整输出,格式化 JSON 便于 diff)
|
||
fmt.Fprint(&buf, "--- body ---\n")
|
||
if len(body) == 0 {
|
||
fmt.Fprint(&buf, " (empty)\n")
|
||
} else {
|
||
var pretty bytes.Buffer
|
||
if json.Indent(&pretty, body, " ", " ") == nil {
|
||
fmt.Fprintf(&buf, " %s\n", pretty.Bytes())
|
||
} else {
|
||
// JSON 格式化失败时原样输出
|
||
fmt.Fprintf(&buf, " %s\n", body)
|
||
}
|
||
}
|
||
|
||
// 写入文件(调试用,并发写入可能交错但不影响可读性)
|
||
_, _ = f.WriteString(buf.String())
|
||
}
|