File size: 6,570 Bytes
b461de0
 
5039d41
b461de0
 
 
 
5039d41
b461de0
 
5039d41
 
 
 
 
 
 
 
b461de0
 
 
 
 
 
5039d41
 
b461de0
5039d41
b461de0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5039d41
 
 
b461de0
 
 
 
 
 
178e3be
b461de0
 
 
 
178e3be
b461de0
 
 
 
 
 
 
 
 
 
 
 
 
 
5039d41
 
 
 
 
 
 
 
 
 
 
 
 
 
b461de0
 
 
 
 
5039d41
b461de0
 
178e3be
b461de0
5039d41
 
 
 
 
b461de0
 
 
 
5039d41
 
 
 
 
 
 
b461de0
5039d41
b461de0
5039d41
 
 
 
 
b461de0
178e3be
b461de0
 
 
 
5039d41
b461de0
178e3be
 
 
 
 
b461de0
 
 
 
 
 
 
 
 
 
5039d41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b461de0
178e3be
b461de0
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import time
import asyncio
from typing import Optional, Dict, Any

from core.config import ConfigManager
from core.noke_client import NokeClient

COOLDOWN_SECONDS = 300
MAX_CONSECUTIVE_ERRORS = 3

# 按错误类别冷却时间(秒)
COOLDOWN_BY_CATEGORY = {
    "auth": 3600,          # 认证失败,长冷却
    "rate_limit": 60,      # 限流,短冷却
    "signature": 60,       # 签名错误,短冷却
    "transient": 300,      # 瞬态错误,默认
}


class TokenManager:
    def __init__(self, config: ConfigManager):
        self.config = config
        self._clients: dict[str, NokeClient] = {}
        self._index: int = 0
        self._cooldowns: dict[str, float] = {}          # token_id -> 冷却截止时间戳
        self._error_categories: dict[str, str] = {}    # token_id -> 最近错误类别
        self._lock = asyncio.Lock()
        self._dirty = False

    @property
    def has_tokens(self) -> bool:
        return len(self.config.get("tokens", default=[])) > 0

    def _get_available_tokens(self) -> list[dict]:
        tokens = self.config.get("tokens", default=[])
        now = time.time()
        available = []
        for t in tokens:
            if not t.get("enabled", True):
                continue
            cooldown_until = self._cooldowns.get(t["id"], 0)
            if now >= cooldown_until:
                if t["id"] in self._cooldowns:
                    del self._cooldowns[t["id"]]
                self._error_categories.pop(t["id"], None)
                t["status"] = "unknown"
                t["error_count"] = 0
                available.append(t)
        return available

    def _get_client(self, token_info: dict) -> NokeClient:
        tid = token_info["id"]
        if tid not in self._clients:
            verify_tls = bool(self.config.get("noke", "verify_tls", default=True))
            self._clients[tid] = NokeClient(
                token_info["value"],
                self.config.get("noke", "base_url"),
                self.config.get("noke", "client_id"),
                verify_tls=verify_tls,
            )
        return self._clients[tid]

    async def get_next(self) -> tuple[Optional[dict], Optional[NokeClient]]:
        async with self._lock:
            available = self._get_available_tokens()
            if not available:
                return None, None
            self._index = self._index % len(available)
            token_info = available[self._index]
            self._index = (self._index + 1) % len(available)
            client = self._get_client(token_info)
            return token_info, client

    async def get_by_id(self, token_id: str) -> tuple[Optional[dict], Optional[NokeClient]]:
        """按 token_id 获取特定 token 和 client(用于会话粘性)"""
        async with self._lock:
            tokens = self.config.get("tokens", default=[])
            for t in tokens:
                if t["id"] == token_id:
                    # 检查是否可用(未冷却)
                    cooldown_until = self._cooldowns.get(token_id, 0)
                    if time.time() < cooldown_until:
                        return None, None
                    client = self._get_client(t)
                    return t, client
            return None, None

    def report_success(self, token_id: str):
        for t in self.config.get("tokens", default=[]):
            if t["id"] == token_id:
                t["total_requests"] = t.get("total_requests", 0) + 1
                t["error_count"] = 0
                t["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
                t["status"] = "active"
                break
        self._dirty = True

    def report_error(self, token_id: str, category: str = "transient"):
        """上报错误,按类别决定冷却策略。
        - auth/rate_limit/signature: 立即进入冷却(冷却时间按类别)
        - transient: 累计错误次数,达到 MAX_CONSECUTIVE_ERRORS 才冷却(COOLDOWN_SECONDS)
        """
        for t in self.config.get("tokens", default=[]):
            if t["id"] == token_id:
                t["error_count"] = t.get("error_count", 0) + 1
                t["total_requests"] = t.get("total_requests", 0) + 1
                t["status"] = "error"
                self._error_categories[token_id] = category

                # 立即冷却类别
                if category in ("auth", "rate_limit", "signature"):
                    cooldown = COOLDOWN_BY_CATEGORY.get(category, COOLDOWN_SECONDS)
                    self._cooldowns[token_id] = time.time() + cooldown
                    t["status"] = "cooldown"
                    t["error_count"] = 0  # 重置计数
                else:
                    # transient: 累计到阈值才冷却
                    if t["error_count"] >= MAX_CONSECUTIVE_ERRORS:
                        self._cooldowns[token_id] = time.time() + COOLDOWN_SECONDS
                        t["status"] = "cooldown"
                        t["error_count"] = 0
                break
        self._dirty = True

    def remove_client(self, token_id: str):
        self._clients.pop(token_id, None)
        self._cooldowns.pop(token_id, None)
        self._error_categories.pop(token_id, None)

    def flush(self):
        if self._dirty:
            self.config.save()
            self._dirty = False

    def get_token_status(self, token_id: str) -> str:
        now = time.time()
        cooldown_until = self._cooldowns.get(token_id, 0)
        if now < cooldown_until:
            return "cooldown"
        for t in self.config.get("tokens", default=[]):
            if t["id"] == token_id:
                return t.get("status", "unknown")
        return "unknown"

    def snapshot(self) -> dict:
        """返回 token 池快照:总数、可用数、冷却中数量"""
        tokens = self.config.get("tokens", default=[])
        total = len(tokens)
        now = time.time()
        available = 0
        cooldown = 0
        for t in tokens:
            if not t.get("enabled", True):
                continue
            cooldown_until = self._cooldowns.get(t["id"], 0)
            if now < cooldown_until:
                cooldown += 1
            else:
                available += 1
        return {
            "total": total,
            "available": available,
            "cooldown": cooldown,
        }

    async def close_all(self):
        self.flush()
        for client in self._clients.values():
            await client.client.aclose()
        self._clients.clear()