- 新增 GetCurrentWindowStartTime() 方法,当窗口过期时自动使用新的预测窗口开始时间 - UpdateSessionWindow 更新窗口时间后触发 outbox 事件同步调度器缓存 - 统一所有窗口费用查询入口使用新方法
734 lines
16 KiB
Go
734 lines
16 KiB
Go
// Package service provides business logic and domain services for the application.
|
||
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type Account struct {
|
||
ID int64
|
||
Name string
|
||
Notes *string
|
||
Platform string
|
||
Type string
|
||
Credentials map[string]any
|
||
Extra map[string]any
|
||
ProxyID *int64
|
||
Concurrency int
|
||
Priority int
|
||
// RateMultiplier 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
|
||
// 使用指针用于兼容旧版本调度缓存(Redis)中缺字段的情况:nil 表示按 1.0 处理。
|
||
RateMultiplier *float64
|
||
Status string
|
||
ErrorMessage string
|
||
LastUsedAt *time.Time
|
||
ExpiresAt *time.Time
|
||
AutoPauseOnExpired bool
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
|
||
Schedulable bool
|
||
|
||
RateLimitedAt *time.Time
|
||
RateLimitResetAt *time.Time
|
||
OverloadUntil *time.Time
|
||
|
||
TempUnschedulableUntil *time.Time
|
||
TempUnschedulableReason string
|
||
|
||
SessionWindowStart *time.Time
|
||
SessionWindowEnd *time.Time
|
||
SessionWindowStatus string
|
||
|
||
Proxy *Proxy
|
||
AccountGroups []AccountGroup
|
||
GroupIDs []int64
|
||
Groups []*Group
|
||
}
|
||
|
||
type TempUnschedulableRule struct {
|
||
ErrorCode int `json:"error_code"`
|
||
Keywords []string `json:"keywords"`
|
||
DurationMinutes int `json:"duration_minutes"`
|
||
Description string `json:"description"`
|
||
}
|
||
|
||
func (a *Account) IsActive() bool {
|
||
return a.Status == StatusActive
|
||
}
|
||
|
||
// BillingRateMultiplier 返回账号计费倍率。
|
||
// - nil 表示未配置/旧缓存缺字段,按 1.0 处理
|
||
// - 允许 0,表示该账号计费为 0
|
||
// - 负数属于非法数据,出于安全考虑按 1.0 处理
|
||
func (a *Account) BillingRateMultiplier() float64 {
|
||
if a == nil || a.RateMultiplier == nil {
|
||
return 1.0
|
||
}
|
||
if *a.RateMultiplier < 0 {
|
||
return 1.0
|
||
}
|
||
return *a.RateMultiplier
|
||
}
|
||
|
||
func (a *Account) IsSchedulable() bool {
|
||
if !a.IsActive() || !a.Schedulable {
|
||
return false
|
||
}
|
||
now := time.Now()
|
||
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
|
||
return false
|
||
}
|
||
if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
|
||
return false
|
||
}
|
||
if a.RateLimitResetAt != nil && now.Before(*a.RateLimitResetAt) {
|
||
return false
|
||
}
|
||
if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (a *Account) IsRateLimited() bool {
|
||
if a.RateLimitResetAt == nil {
|
||
return false
|
||
}
|
||
return time.Now().Before(*a.RateLimitResetAt)
|
||
}
|
||
|
||
func (a *Account) IsOverloaded() bool {
|
||
if a.OverloadUntil == nil {
|
||
return false
|
||
}
|
||
return time.Now().Before(*a.OverloadUntil)
|
||
}
|
||
|
||
func (a *Account) IsOAuth() bool {
|
||
return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken
|
||
}
|
||
|
||
func (a *Account) IsGemini() bool {
|
||
return a.Platform == PlatformGemini
|
||
}
|
||
|
||
func (a *Account) GeminiOAuthType() string {
|
||
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
|
||
return ""
|
||
}
|
||
oauthType := strings.TrimSpace(a.GetCredential("oauth_type"))
|
||
if oauthType == "" && strings.TrimSpace(a.GetCredential("project_id")) != "" {
|
||
return "code_assist"
|
||
}
|
||
return oauthType
|
||
}
|
||
|
||
func (a *Account) GeminiTierID() string {
|
||
tierID := strings.TrimSpace(a.GetCredential("tier_id"))
|
||
return tierID
|
||
}
|
||
|
||
func (a *Account) IsGeminiCodeAssist() bool {
|
||
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
|
||
return false
|
||
}
|
||
oauthType := a.GeminiOAuthType()
|
||
if oauthType == "" {
|
||
return strings.TrimSpace(a.GetCredential("project_id")) != ""
|
||
}
|
||
return oauthType == "code_assist"
|
||
}
|
||
|
||
func (a *Account) CanGetUsage() bool {
|
||
return a.Type == AccountTypeOAuth
|
||
}
|
||
|
||
func (a *Account) GetCredential(key string) string {
|
||
if a.Credentials == nil {
|
||
return ""
|
||
}
|
||
v, ok := a.Credentials[key]
|
||
if !ok || v == nil {
|
||
return ""
|
||
}
|
||
|
||
// 支持多种类型(兼容历史数据中 expires_at 等字段可能是数字或字符串)
|
||
switch val := v.(type) {
|
||
case string:
|
||
return val
|
||
case json.Number:
|
||
// GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
|
||
return val.String()
|
||
case float64:
|
||
// JSON 解析后数字默认为 float64
|
||
return strconv.FormatInt(int64(val), 10)
|
||
case int64:
|
||
return strconv.FormatInt(val, 10)
|
||
case int:
|
||
return strconv.Itoa(val)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// GetCredentialAsTime 解析凭证中的时间戳字段,支持多种格式
|
||
// 兼容以下格式:
|
||
// - RFC3339 字符串: "2025-01-01T00:00:00Z"
|
||
// - Unix 时间戳字符串: "1735689600"
|
||
// - Unix 时间戳数字: 1735689600 (float64/int64/json.Number)
|
||
func (a *Account) GetCredentialAsTime(key string) *time.Time {
|
||
s := a.GetCredential(key)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
// 尝试 RFC3339 格式
|
||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||
return &t
|
||
}
|
||
// 尝试 Unix 时间戳(纯数字字符串)
|
||
if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||
t := time.Unix(ts, 0)
|
||
return &t
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Account) IsTempUnschedulableEnabled() bool {
|
||
if a.Credentials == nil {
|
||
return false
|
||
}
|
||
raw, ok := a.Credentials["temp_unschedulable_enabled"]
|
||
if !ok || raw == nil {
|
||
return false
|
||
}
|
||
enabled, ok := raw.(bool)
|
||
return ok && enabled
|
||
}
|
||
|
||
func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
|
||
if a.Credentials == nil {
|
||
return nil
|
||
}
|
||
raw, ok := a.Credentials["temp_unschedulable_rules"]
|
||
if !ok || raw == nil {
|
||
return nil
|
||
}
|
||
|
||
arr, ok := raw.([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
|
||
rules := make([]TempUnschedulableRule, 0, len(arr))
|
||
for _, item := range arr {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok || entry == nil {
|
||
continue
|
||
}
|
||
|
||
rule := TempUnschedulableRule{
|
||
ErrorCode: parseTempUnschedInt(entry["error_code"]),
|
||
Keywords: parseTempUnschedStrings(entry["keywords"]),
|
||
DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]),
|
||
Description: parseTempUnschedString(entry["description"]),
|
||
}
|
||
|
||
if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 {
|
||
continue
|
||
}
|
||
|
||
rules = append(rules, rule)
|
||
}
|
||
|
||
return rules
|
||
}
|
||
|
||
func parseTempUnschedString(value any) string {
|
||
s, ok := value.(string)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
func parseTempUnschedStrings(value any) []string {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
|
||
var raw []string
|
||
switch v := value.(type) {
|
||
case []string:
|
||
raw = v
|
||
case []any:
|
||
raw = make([]string, 0, len(v))
|
||
for _, item := range v {
|
||
if s, ok := item.(string); ok {
|
||
raw = append(raw, s)
|
||
}
|
||
}
|
||
default:
|
||
return nil
|
||
}
|
||
|
||
out := make([]string, 0, len(raw))
|
||
for _, item := range raw {
|
||
s := strings.TrimSpace(item)
|
||
if s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeAccountNotes(value *string) *string {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
trimmed := strings.TrimSpace(*value)
|
||
if trimmed == "" {
|
||
return nil
|
||
}
|
||
return &trimmed
|
||
}
|
||
|
||
func parseTempUnschedInt(value any) int {
|
||
switch v := value.(type) {
|
||
case int:
|
||
return v
|
||
case int64:
|
||
return int(v)
|
||
case float64:
|
||
return int(v)
|
||
case json.Number:
|
||
if i, err := v.Int64(); err == nil {
|
||
return int(i)
|
||
}
|
||
case string:
|
||
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (a *Account) GetModelMapping() map[string]string {
|
||
if a.Credentials == nil {
|
||
return nil
|
||
}
|
||
raw, ok := a.Credentials["model_mapping"]
|
||
if !ok || raw == nil {
|
||
return nil
|
||
}
|
||
if m, ok := raw.(map[string]any); ok {
|
||
result := make(map[string]string)
|
||
for k, v := range m {
|
||
if s, ok := v.(string); ok {
|
||
result[k] = s
|
||
}
|
||
}
|
||
if len(result) > 0 {
|
||
return result
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Account) IsModelSupported(requestedModel string) bool {
|
||
mapping := a.GetModelMapping()
|
||
if len(mapping) == 0 {
|
||
return true
|
||
}
|
||
_, exists := mapping[requestedModel]
|
||
return exists
|
||
}
|
||
|
||
func (a *Account) GetMappedModel(requestedModel string) string {
|
||
mapping := a.GetModelMapping()
|
||
if len(mapping) == 0 {
|
||
return requestedModel
|
||
}
|
||
if mappedModel, exists := mapping[requestedModel]; exists {
|
||
return mappedModel
|
||
}
|
||
return requestedModel
|
||
}
|
||
|
||
func (a *Account) GetBaseURL() string {
|
||
if a.Type != AccountTypeAPIKey {
|
||
return ""
|
||
}
|
||
baseURL := a.GetCredential("base_url")
|
||
if baseURL == "" {
|
||
return "https://api.anthropic.com"
|
||
}
|
||
return baseURL
|
||
}
|
||
|
||
func (a *Account) GetExtraString(key string) string {
|
||
if a.Extra == nil {
|
||
return ""
|
||
}
|
||
if v, ok := a.Extra[key]; ok {
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (a *Account) IsCustomErrorCodesEnabled() bool {
|
||
if a.Type != AccountTypeAPIKey || a.Credentials == nil {
|
||
return false
|
||
}
|
||
if v, ok := a.Credentials["custom_error_codes_enabled"]; ok {
|
||
if enabled, ok := v.(bool); ok {
|
||
return enabled
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (a *Account) GetCustomErrorCodes() []int {
|
||
if a.Credentials == nil {
|
||
return nil
|
||
}
|
||
raw, ok := a.Credentials["custom_error_codes"]
|
||
if !ok || raw == nil {
|
||
return nil
|
||
}
|
||
if arr, ok := raw.([]any); ok {
|
||
result := make([]int, 0, len(arr))
|
||
for _, v := range arr {
|
||
if f, ok := v.(float64); ok {
|
||
result = append(result, int(f))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Account) ShouldHandleErrorCode(statusCode int) bool {
|
||
if !a.IsCustomErrorCodesEnabled() {
|
||
return true
|
||
}
|
||
codes := a.GetCustomErrorCodes()
|
||
if len(codes) == 0 {
|
||
return true
|
||
}
|
||
for _, code := range codes {
|
||
if code == statusCode {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (a *Account) IsInterceptWarmupEnabled() bool {
|
||
if a.Credentials == nil {
|
||
return false
|
||
}
|
||
if v, ok := a.Credentials["intercept_warmup_requests"]; ok {
|
||
if enabled, ok := v.(bool); ok {
|
||
return enabled
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (a *Account) IsOpenAI() bool {
|
||
return a.Platform == PlatformOpenAI
|
||
}
|
||
|
||
func (a *Account) IsAnthropic() bool {
|
||
return a.Platform == PlatformAnthropic
|
||
}
|
||
|
||
func (a *Account) IsOpenAIOAuth() bool {
|
||
return a.IsOpenAI() && a.Type == AccountTypeOAuth
|
||
}
|
||
|
||
func (a *Account) IsOpenAIApiKey() bool {
|
||
return a.IsOpenAI() && a.Type == AccountTypeAPIKey
|
||
}
|
||
|
||
func (a *Account) GetOpenAIBaseURL() string {
|
||
if !a.IsOpenAI() {
|
||
return ""
|
||
}
|
||
if a.Type == AccountTypeAPIKey {
|
||
baseURL := a.GetCredential("base_url")
|
||
if baseURL != "" {
|
||
return baseURL
|
||
}
|
||
}
|
||
return "https://api.openai.com"
|
||
}
|
||
|
||
func (a *Account) GetOpenAIAccessToken() string {
|
||
if !a.IsOpenAI() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("access_token")
|
||
}
|
||
|
||
func (a *Account) GetOpenAIRefreshToken() string {
|
||
if !a.IsOpenAIOAuth() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("refresh_token")
|
||
}
|
||
|
||
func (a *Account) GetOpenAIIDToken() string {
|
||
if !a.IsOpenAIOAuth() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("id_token")
|
||
}
|
||
|
||
func (a *Account) GetOpenAIApiKey() string {
|
||
if !a.IsOpenAIApiKey() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("api_key")
|
||
}
|
||
|
||
func (a *Account) GetOpenAIUserAgent() string {
|
||
if !a.IsOpenAI() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("user_agent")
|
||
}
|
||
|
||
func (a *Account) GetChatGPTAccountID() string {
|
||
if !a.IsOpenAIOAuth() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("chatgpt_account_id")
|
||
}
|
||
|
||
func (a *Account) GetChatGPTUserID() string {
|
||
if !a.IsOpenAIOAuth() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("chatgpt_user_id")
|
||
}
|
||
|
||
func (a *Account) GetOpenAIOrganizationID() string {
|
||
if !a.IsOpenAIOAuth() {
|
||
return ""
|
||
}
|
||
return a.GetCredential("organization_id")
|
||
}
|
||
|
||
func (a *Account) GetOpenAITokenExpiresAt() *time.Time {
|
||
if !a.IsOpenAIOAuth() {
|
||
return nil
|
||
}
|
||
return a.GetCredentialAsTime("expires_at")
|
||
}
|
||
|
||
func (a *Account) IsOpenAITokenExpired() bool {
|
||
expiresAt := a.GetOpenAITokenExpiresAt()
|
||
if expiresAt == nil {
|
||
return false
|
||
}
|
||
return time.Now().Add(60 * time.Second).After(*expiresAt)
|
||
}
|
||
|
||
// IsMixedSchedulingEnabled 检查 antigravity 账户是否启用混合调度
|
||
// 启用后可参与 anthropic/gemini 分组的账户调度
|
||
func (a *Account) IsMixedSchedulingEnabled() bool {
|
||
if a.Platform != PlatformAntigravity {
|
||
return false
|
||
}
|
||
if a.Extra == nil {
|
||
return false
|
||
}
|
||
if v, ok := a.Extra["mixed_scheduling"]; ok {
|
||
if enabled, ok := v.(bool); ok {
|
||
return enabled
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// WindowCostSchedulability 窗口费用调度状态
|
||
type WindowCostSchedulability int
|
||
|
||
const (
|
||
// WindowCostSchedulable 可正常调度
|
||
WindowCostSchedulable WindowCostSchedulability = iota
|
||
// WindowCostStickyOnly 仅允许粘性会话
|
||
WindowCostStickyOnly
|
||
// WindowCostNotSchedulable 完全不可调度
|
||
WindowCostNotSchedulable
|
||
)
|
||
|
||
// IsAnthropicOAuthOrSetupToken 判断是否为 Anthropic OAuth 或 SetupToken 类型账号
|
||
// 仅这两类账号支持 5h 窗口额度控制和会话数量控制
|
||
func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
|
||
return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
|
||
}
|
||
|
||
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
|
||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
|
||
func (a *Account) IsTLSFingerprintEnabled() bool {
|
||
// 仅支持 Anthropic OAuth/SetupToken 账号
|
||
if !a.IsAnthropicOAuthOrSetupToken() {
|
||
return false
|
||
}
|
||
if a.Extra == nil {
|
||
return false
|
||
}
|
||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||
if enabled, ok := v.(bool); ok {
|
||
return enabled
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
|
||
// 返回 0 表示未启用
|
||
func (a *Account) GetWindowCostLimit() float64 {
|
||
if a.Extra == nil {
|
||
return 0
|
||
}
|
||
if v, ok := a.Extra["window_cost_limit"]; ok {
|
||
return parseExtraFloat64(v)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// GetWindowCostStickyReserve 获取粘性会话预留额度(美元)
|
||
// 默认值为 10
|
||
func (a *Account) GetWindowCostStickyReserve() float64 {
|
||
if a.Extra == nil {
|
||
return 10.0
|
||
}
|
||
if v, ok := a.Extra["window_cost_sticky_reserve"]; ok {
|
||
val := parseExtraFloat64(v)
|
||
if val > 0 {
|
||
return val
|
||
}
|
||
}
|
||
return 10.0
|
||
}
|
||
|
||
// GetMaxSessions 获取最大并发会话数
|
||
// 返回 0 表示未启用
|
||
func (a *Account) GetMaxSessions() int {
|
||
if a.Extra == nil {
|
||
return 0
|
||
}
|
||
if v, ok := a.Extra["max_sessions"]; ok {
|
||
return parseExtraInt(v)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// GetSessionIdleTimeoutMinutes 获取会话空闲超时分钟数
|
||
// 默认值为 5 分钟
|
||
func (a *Account) GetSessionIdleTimeoutMinutes() int {
|
||
if a.Extra == nil {
|
||
return 5
|
||
}
|
||
if v, ok := a.Extra["session_idle_timeout_minutes"]; ok {
|
||
val := parseExtraInt(v)
|
||
if val > 0 {
|
||
return val
|
||
}
|
||
}
|
||
return 5
|
||
}
|
||
|
||
// CheckWindowCostSchedulability 根据当前窗口费用检查调度状态
|
||
// - 费用 < 阈值: WindowCostSchedulable(可正常调度)
|
||
// - 费用 >= 阈值 且 < 阈值+预留: WindowCostStickyOnly(仅粘性会话)
|
||
// - 费用 >= 阈值+预留: WindowCostNotSchedulable(不可调度)
|
||
func (a *Account) CheckWindowCostSchedulability(currentWindowCost float64) WindowCostSchedulability {
|
||
limit := a.GetWindowCostLimit()
|
||
if limit <= 0 {
|
||
return WindowCostSchedulable
|
||
}
|
||
|
||
if currentWindowCost < limit {
|
||
return WindowCostSchedulable
|
||
}
|
||
|
||
stickyReserve := a.GetWindowCostStickyReserve()
|
||
if currentWindowCost < limit+stickyReserve {
|
||
return WindowCostStickyOnly
|
||
}
|
||
|
||
return WindowCostNotSchedulable
|
||
}
|
||
|
||
// GetCurrentWindowStartTime 获取当前有效的窗口开始时间
|
||
// 逻辑:
|
||
// 1. 如果窗口未过期(SessionWindowEnd 存在且在当前时间之后),使用记录的 SessionWindowStart
|
||
// 2. 否则(窗口过期或未设置),使用新的预测窗口开始时间(从当前整点开始)
|
||
func (a *Account) GetCurrentWindowStartTime() time.Time {
|
||
now := time.Now()
|
||
|
||
// 窗口未过期,使用记录的窗口开始时间
|
||
if a.SessionWindowStart != nil && a.SessionWindowEnd != nil && now.Before(*a.SessionWindowEnd) {
|
||
return *a.SessionWindowStart
|
||
}
|
||
|
||
// 窗口已过期或未设置,预测新的窗口开始时间(从当前整点开始)
|
||
// 与 ratelimit_service.go 中 UpdateSessionWindow 的预测逻辑保持一致
|
||
return time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||
}
|
||
|
||
// parseExtraFloat64 从 extra 字段解析 float64 值
|
||
func parseExtraFloat64(value any) float64 {
|
||
switch v := value.(type) {
|
||
case float64:
|
||
return v
|
||
case float32:
|
||
return float64(v)
|
||
case int:
|
||
return float64(v)
|
||
case int64:
|
||
return float64(v)
|
||
case json.Number:
|
||
if f, err := v.Float64(); err == nil {
|
||
return f
|
||
}
|
||
case string:
|
||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||
return f
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// parseExtraInt 从 extra 字段解析 int 值
|
||
func parseExtraInt(value any) int {
|
||
switch v := value.(type) {
|
||
case int:
|
||
return v
|
||
case int64:
|
||
return int(v)
|
||
case float64:
|
||
return int(v)
|
||
case json.Number:
|
||
if i, err := v.Int64(); err == nil {
|
||
return int(i)
|
||
}
|
||
case string:
|
||
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|