From dd56de9bd46e3605390a3f4fac83913f25012d8e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 23 Jun 2026 08:35:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dproxy=5Fservice?= =?UTF-8?q?=E5=92=8Caccount=5Fservice=E4=B8=AD=E4=B8=8D=E5=BD=93=E7=9A=84?= =?UTF-8?q?=E9=A1=B6=E5=B1=82import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxy_service.py: 将requests和WhitelistManager改为函数内延迟import, 避免启动时加载不需要的依赖;移除未使用的get_exit_ip_via_proxy导入 - account_service.py: 移除未使用的func、joinedload、AuditLog、 user_has_permission顶层导入 --- Dockerfile | 2 +- README.md | 2 + alembic.ini | 38 ++ core/douyu/proxy.py | 482 +-------------- core/douyu/proxy_manager.py | 165 ++++++ core/douyu/proxy_parser.py | 59 ++ core/douyu/proxy_resolver.py | 175 ++++++ core/douyu/proxy_verifier.py | 114 ++++ core/douyu/proxy_whitelist.py | 20 + pyproject.toml | 1 + uv.lock | 548 +++++++++++++++++- web/backend/database.py | 59 +- web/backend/migrations/README.md | 12 + web/backend/migrations/env.py | 57 ++ web/backend/migrations/script.py.mako | 25 + .../20260623_0001_initial_web_schema.py | 160 +++++ web/backend/services/account_service.py | 6 +- web/backend/services/proxy_service.py | 4 +- web/frontend/src/api/accounts.ts | 28 + web/frontend/src/api/auth.ts | 11 + web/frontend/src/api/client.ts | 22 + web/frontend/src/api/cookies.ts | 8 + web/frontend/src/api/index.ts | 23 +- web/frontend/src/api/login.ts | 12 + web/frontend/src/api/logs.ts | 8 + web/frontend/src/api/modules.ts | 236 +------- web/frontend/src/api/proxy.ts | 9 + web/frontend/src/api/types.ts | 156 +++++ web/frontend/src/api/users.ts | 12 + .../src/components/RealtimeLogPanel.tsx | 112 ++++ web/frontend/src/hooks/usePermissions.ts | 23 + web/frontend/src/hooks/useWebSocketLogs.ts | 102 ++++ web/frontend/src/layouts/MainLayout.tsx | 22 +- web/frontend/src/pages/AccountsPage.tsx | 23 +- web/frontend/src/pages/AssignmentsPage.tsx | 6 +- web/frontend/src/pages/CookiePage.tsx | 8 +- web/frontend/src/pages/HttpLogsPage.tsx | 6 +- web/frontend/src/pages/LoginTasksPage.tsx | 106 +--- web/frontend/src/pages/ProxyPage.tsx | 75 +-- web/frontend/src/pages/UsersPage.tsx | 5 +- 40 files changed, 2006 insertions(+), 936 deletions(-) create mode 100644 alembic.ini create mode 100644 core/douyu/proxy_manager.py create mode 100644 core/douyu/proxy_parser.py create mode 100644 core/douyu/proxy_resolver.py create mode 100644 core/douyu/proxy_verifier.py create mode 100644 core/douyu/proxy_whitelist.py create mode 100644 web/backend/migrations/README.md create mode 100644 web/backend/migrations/env.py create mode 100644 web/backend/migrations/script.py.mako create mode 100644 web/backend/migrations/versions/20260623_0001_initial_web_schema.py create mode 100644 web/frontend/src/api/accounts.ts create mode 100644 web/frontend/src/api/auth.ts create mode 100644 web/frontend/src/api/client.ts create mode 100644 web/frontend/src/api/cookies.ts create mode 100644 web/frontend/src/api/login.ts create mode 100644 web/frontend/src/api/logs.ts create mode 100644 web/frontend/src/api/proxy.ts create mode 100644 web/frontend/src/api/types.ts create mode 100644 web/frontend/src/api/users.ts create mode 100644 web/frontend/src/components/RealtimeLogPanel.tsx create mode 100644 web/frontend/src/hooks/usePermissions.ts create mode 100644 web/frontend/src/hooks/useWebSocketLogs.ts diff --git a/Dockerfile b/Dockerfile index 366328e..044bdc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app # 先复制依赖文件,利用 Docker 缓存层 -COPY pyproject.toml uv.lock* README.md ./ +COPY pyproject.toml uv.lock* README.md alembic.ini ./ RUN uv venv /app/.venv && uv pip install -e . # 复制项目代码 diff --git a/README.md b/README.md index d2343d6..80df0c2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ douyu_login_py/ │ ├── backend/ # FastAPI 后端 │ │ ├── main.py # 入口 │ │ ├── routers/ # API 路由 +│ │ ├── migrations/ # Alembic 数据库迁移 │ │ └── services/ # 业务服务 │ └── frontend/ # React + Ant Design 前端 ├── utils/ # 通用工具 @@ -79,6 +80,7 @@ cd web/frontend && npm install && npm run dev - 后端:FastAPI + SQLAlchemy + SQLite + JWT - 前端:React + Ant Design + Vite - 核心:Python + OpenCV + pycryptodome +- 迁移:Alembic ## 许可证 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..bed0014 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = web/backend/migrations +prepend_sys_path = . +sqlalchemy.url = sqlite:///data/web.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/core/douyu/proxy.py b/core/douyu/proxy.py index 40bdd69..bb65408 100644 --- a/core/douyu/proxy.py +++ b/core/douyu/proxy.py @@ -1,470 +1,16 @@ -"""代理管理模块""" +"""代理管理模块兼容导出。""" -import json -import re -import threading -import time -import requests -from typing import Optional -from loguru import logger +from .proxy_manager import ProxyManager, get_proxy_manager +from .proxy_parser import parse_proxy_response +from .proxy_resolver import ProxyResolver, resolve_working_proxy +from .proxy_verifier import verify_proxies_concurrent, verify_proxy_url - -class ProxyManager: - """代理管理器(带已验证代理池缓存,批次内共享复用)""" - - def __init__(self, api_url: str = "", whitelist_uid: str = "", whitelist_ukey: str = ""): - self.api_url = api_url - self.whitelist_uid = whitelist_uid - self.whitelist_ukey = whitelist_ukey - self.current_proxy: Optional[str] = None - self._cond = threading.Condition() - # 已验证可用的代理池: {proxy_url: validated_timestamp} - self._verified_pool: dict[str, float] = {} - # 正在使用中的代理(取走但未归还),避免并发账号用同一个代理 - self._in_use: set[str] = set() - self._pool_ttl = 90 # 代理验证后90秒内可复用 - self._fetching = False # 是否有线程正在获取代理 - - def _pick_from_pool_locked(self) -> Optional[str]: - """从池中取一个未过期且未在使用的代理(调用前需持有锁)""" - now = time.time() - # 清理过期代理 - expired = [p for p, t in self._verified_pool.items() if now - t > self._pool_ttl] - for p in expired: - del self._verified_pool[p] - self._in_use.discard(p) - # 取一个不在使用中的 - for proxy in self._verified_pool: - if proxy not in self._in_use: - self._in_use.add(proxy) - self.current_proxy = proxy - return proxy - # 所有代理都在使用中,但池非空(并发数>代理数),允许复用第一个 - for proxy in self._verified_pool: - self.current_proxy = proxy - return proxy - return None - - def get_proxy(self, max_attempts: int = 5) -> Optional[str]: - """ - 获取可用代理IP,优先从已验证代理池复用。 - 线程安全:池空时只有一个线程调API获取并验证所有代理入池,其他线程等待后复用。 - - Returns: - 代理URL,格式: http://ip:port - """ - with self._cond: - # 1. 优先从池中取未过期的 - proxy = self._pick_from_pool_locked() - if proxy: - logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})") - return proxy - # 2. 已有线程在获取,等待结果 - if self._fetching: - logger.debug("代理池空,等待其他线程获取...") - self._cond.wait(timeout=60) - proxy = self._pick_from_pool_locked() - if proxy: - logger.debug(f"等待后从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})") - return proxy - return None - # 3. 自己去获取 - self._fetching = True - - # 释放锁后执行耗时的API调用+验证 - try: - proxies_all, msg = self._fetch_and_verify_all(max_attempts) - finally: - with self._cond: - self._fetching = False - self._cond.notify_all() - - if proxies_all: - with self._cond: - now = time.time() - for p in proxies_all: - self._verified_pool[p] = now - first = proxies_all[0] - self.current_proxy = first - logger.info(f"代理池充盈 {len(proxies_all)} 个可用代理: {first} ...") - return first - logger.warning(f"获取代理失败: {msg}") - return None - - def _fetch_and_verify_all(self, max_attempts: int) -> tuple[Optional[list[str]], str]: - """调代理API获取一批代理,并发验证所有可用代理,自动处理白名单同步。""" - last_error = "" - last_synced_ip: Optional[str] = None - - for attempt in range(1, max_attempts + 1): - if attempt > 1: - time.sleep(min(attempt - 1, 2)) - - # 每次尝试前主动同步白名单(出口IP可能漂移,需重新同步) - if self.whitelist_uid and self.whitelist_ukey: - from core.douyu.whitelist import get_local_exit_ip, WhitelistManager - local_ip = get_local_exit_ip() - if local_ip and local_ip != last_synced_ip: - logger.info(f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...") - manager = WhitelistManager(self.whitelist_uid, self.whitelist_ukey) - ok, sync_msg = manager.sync_ip(local_ip) - if ok: - last_synced_ip = local_ip - logger.info(f"白名单同步成功: {sync_msg}") - # 等待白名单生效 - time.sleep(2) - else: - logger.warning(f"白名单同步失败: {sync_msg}") - elif local_ip == last_synced_ip: - logger.debug(f"[尝试 {attempt}] 出口IP未变: {local_ip}") - - try: - response = requests.get(self.api_url, timeout=10) - response.raise_for_status() - text = response.text.strip() - - proxy_urls, whitelist_ip = parse_proxy_response(text) - - if proxy_urls: - logger.info(f"获取到 {len(proxy_urls)} 个代理,并发验证所有") - available, msg = verify_proxies_concurrent(proxy_urls, return_all=True) - if available: - return available, msg - last_error = msg - logger.warning(f"代理预检 {attempt}/{max_attempts}: {last_error}") - continue - - # 代理API返回白名单错误 - if whitelist_ip and self.whitelist_uid and self.whitelist_ukey: - logger.warning(f"代理需要白名单IP: {whitelist_ip},自动同步...") - from core.douyu.whitelist import WhitelistManager - manager = WhitelistManager(self.whitelist_uid, self.whitelist_ukey) - ok, sync_msg = manager.sync_ip(whitelist_ip) - if ok: - last_synced_ip = whitelist_ip - logger.info("白名单已更新,等待2秒后重试...") - time.sleep(2) - continue - return None, f'白名单同步失败: {sync_msg}' - - last_error = '代理API响应无法解析' - logger.warning(f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}") - - except Exception as exc: - last_error = f'代理API请求失败: {exc}' - logger.warning(f"代理预检 {attempt}/{max_attempts}: {last_error}") - - return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}' - - def mark_bad(self, proxy_url: str) -> None: - """标记代理为不可用,从池中移除(极验失败/代理连接失败时调用)""" - with self._cond: - removed = self._verified_pool.pop(proxy_url, None) - self._in_use.discard(proxy_url) - if self.current_proxy == proxy_url: - self.current_proxy = None - if removed: - logger.info(f"代理标记为不可用并移出池: {proxy_url} (池剩余 {len(self._verified_pool)})") - self._cond.notify_all() - - def release_proxy(self, proxy_url: str) -> None: - """归还代理到池(登录完成后调用,让其他账号可以复用)""" - with self._cond: - self._in_use.discard(proxy_url) - self._cond.notify_all() - - def get_proxies_dict(self, proxy: str = None) -> dict: - """获取requests使用的proxies字典""" - proxy = proxy or self.current_proxy - if proxy: - return {'http': proxy, 'https': proxy} - return {} - - def verify_proxy(self, proxy: str = None) -> bool: - """验证代理是否可用""" - proxy = proxy or self.current_proxy - if not proxy: - return False - try: - response = requests.get( - 'https://httpbin.org/ip', - proxies={'http': proxy, 'https': proxy}, - timeout=10 - ) - if response.status_code == 200: - data = response.json() - logger.info(f"代理验证成功,当前IP: {data.get('origin')}") - return True - return False - except Exception as e: - logger.error(f"代理验证失败: {e}") - return False - - -def get_proxy_manager( - api_url: str = "", - whitelist_uid: str = "", - whitelist_ukey: str = "", -) -> ProxyManager: - """获取代理管理器实例""" - return ProxyManager(api_url, whitelist_uid=whitelist_uid, whitelist_ukey=whitelist_ukey) - - -def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]: - """ - 解析代理API响应,支持 JSON 格式与旧版纯文本格式。 - - JSON 格式示例: - 正常: {"code":0,"success":"true","msg":"","data":[{"IP":"1.2.3.4","Port":5791,...}]} - 白名单错误: {"code":-1,"success":"true","msg":"51.请先添加白名单:39.144.109.21","data":""} - - 旧版纯文本格式(兼容): - 正常: 1.2.3.4:5791\\n5.6.7.8:8080 - 白名单错误: 请先添加白名单:39.144.109.21 - - Returns: - (proxy_urls, whitelist_ip) - - proxy_urls: 解析到的所有代理地址列表(http://ip:port) - - whitelist_ip: 需要添加到白名单的IP(当API返回白名单错误时),无错误时为 None - """ - text = (text or "").strip() - if not text: - return [], None - - # 优先尝试 JSON 解析 - try: - data = json.loads(text) - if isinstance(data, dict): - # 白名单错误:code != 0 且 msg 含白名单提示 - code = data.get("code") - msg = data.get("msg", "") or "" - if code != 0 and ("白名单" in msg or "添加白名单" in msg): - ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg) - if ip_match: - return [], ip_match.group(1) - - # 正常返回:从 data 数组提取 IP/Port - data_field = data.get("data") - proxies: list[str] = [] - if isinstance(data_field, list): - for item in data_field: - if not isinstance(item, dict): - continue - ip = item.get("IP") or item.get("ip") - port = item.get("Port") or item.get("port") - if ip and port: - proxies.append(f"http://{ip}:{port}") - if proxies: - return proxies, None - - # data 为空但 code==0,可能代理暂时不可用 - if code == 0 and not proxies: - return [], None - except (json.JSONDecodeError, ValueError): - # 不是 JSON,回退到文本解析 - pass - - # 旧版文本格式:白名单错误优先检测 - if '添加白名单' in text or '白名单' in text: - ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) - if ip_match: - return [], ip_match.group(1) - - # 旧版文本格式:解析所有 ip:port - matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) - proxies = [f"http://{ip}:{port}" for ip, port in matches] - if proxies: - return proxies, None - - return [], None - - -def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str]: - """ - 验证代理是否可用,只验证斗鱼主站(登录的最终目标)。 - - Returns: - (是否可用, 消息) - """ - from utils.http_logger import log_http - import time as _time - - proxies = {'http': proxy_url, 'https': proxy_url} - started = _time.monotonic() - try: - response = requests.get( - 'https://www.douyu.com', - proxies=proxies, - timeout=timeout, - headers={'User-Agent': 'Mozilla/5.0'}, - ) - elapsed = _time.monotonic() - started - response.raise_for_status() - log_http( - category="proxy_verify", method="GET", url="https://www.douyu.com", - status_code=response.status_code, response_body=f"代理可用: {proxy_url}", - duration=elapsed, proxy=proxy_url, tag="proxy_verify", - ) - return True, '代理可用 → 斗鱼主站' - except Exception as e: - elapsed = _time.monotonic() - started - err_msg = str(e) - if 'Tunnel connection failed' in err_msg or '503' in err_msg: - detail = '代理拒绝连接(白名单可能未生效)' - elif 'timed out' in err_msg.lower(): - detail = '连接超时' - else: - detail = type(e).__name__ - logger.debug(f"代理验证失败 [{proxy_url}]: {detail}") - log_http( - category="proxy_verify", method="GET", url="https://www.douyu.com", - duration=elapsed, error=f"[{proxy_url}] {detail}: {err_msg[:200]}", - proxy=proxy_url, tag="proxy_verify", - ) - return False, detail - - -def verify_proxies_concurrent(proxy_urls: list[str], timeout: tuple = (5, 8), max_workers: int = 5, return_all: bool = False) -> tuple[Optional[str | list[str]], str]: - """ - 并发验证多个代理URL。 - - Args: - proxy_urls: 代理URL列表 - timeout: 验证超时 - max_workers: 最大并发数 - return_all: True 时返回所有可用代理列表;False(默认)返回第一个可用的 - - Returns: - return_all=False: (可用代理URL或None, 消息) - return_all=True: (可用代理URL列表或None, 消息) - """ - if not proxy_urls: - return None, '无代理可验证' - - if len(proxy_urls) == 1: - ok, msg = verify_proxy_url(proxy_urls[0], timeout) - if ok: - return (proxy_urls if return_all else proxy_urls[0]), msg - return None, msg - - from concurrent.futures import ThreadPoolExecutor, as_completed - - if return_all: - # 收集所有可用代理(不取消任何任务) - available: list[str] = [] - with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: - future_map = { - executor.submit(verify_proxy_url, p, timeout): p - for p in proxy_urls - } - for future in as_completed(future_map): - try: - ok, _ = future.result() - if ok: - available.append(future_map[future]) - except Exception: - continue - if available: - logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理") - return available, f'找到 {len(available)} 个可用代理' - return None, f'共 {len(proxy_urls)} 个代理均不可用' - - # 默认:返回第一个可用的,取消其余 - with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: - future_map = { - executor.submit(verify_proxy_url, p, timeout): p - for p in proxy_urls - } - for future in as_completed(future_map): - proxy_url = future_map[future] - try: - ok, msg = future.result() - if ok: - logger.success(f"并发验证找到可用代理: {proxy_url}") - for f in future_map: - if f != future: - f.cancel() - return proxy_url, msg - except Exception: - continue - - return None, f'共 {len(proxy_urls)} 个代理均不可用' - - -def resolve_working_proxy( - api_url: str, - whitelist_uid: str = "", - whitelist_ukey: str = "", - max_attempts: int = 4, - log_func=None, -) -> tuple[Optional[str], str]: - """ - 从代理API获取可用代理,自动处理白名单同步。 - 支持API返回多个代理IP,逐一验证直到找到可用的。 - - Args: - api_url: 代理API地址 - whitelist_uid: 白名单UID(启用白名单时传入) - whitelist_ukey: 白名单UKEY - max_attempts: 最大获取尝试次数(默认4次) - log_func: 日志回调函数 (level, message) - - Returns: - (代理URL, 消息) - """ - def log(level, msg): - if log_func: - log_func(level, msg) - else: - getattr(logger, level if level in ('info', 'warning', 'error', 'success') else 'info', logger.info)(msg) - - synced_whitelist = False - last_error = "" - - for attempt in range(1, max_attempts + 1): - # 重试之间增加退避延迟,避免代理API返回同一个不可用IP - if attempt > 1: - delay = min(attempt - 1, 2) - log('info', f'等待 {delay}s 后重试...') - time.sleep(delay) - - log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理') - try: - response = requests.get(api_url, timeout=10) - response.raise_for_status() - text = response.text.strip() - - proxy_urls, whitelist_ip = parse_proxy_response(text) - - if proxy_urls: - log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证') - proxy_url, msg = verify_proxies_concurrent(proxy_urls) - if proxy_url: - log('success', f'代理预检成功: {proxy_url}') - return proxy_url, msg - last_error = msg - log('warning', f'代理预检 {attempt}/{max_attempts}: {last_error}') - continue - - # 代理API返回白名单错误 - if whitelist_ip and not synced_whitelist and whitelist_uid and whitelist_ukey: - log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...') - from core.douyu.whitelist import WhitelistManager - manager = WhitelistManager(whitelist_uid, whitelist_ukey) - ok, sync_msg = manager.sync_ip(whitelist_ip) - log('success' if ok else 'error', f'白名单同步: {sync_msg}') - if ok: - synced_whitelist = True - log('info', '白名单已更新,等待2秒后重试...') - time.sleep(2) - continue - return None, f'白名单同步失败: {sync_msg}' - - last_error = f'代理API响应无法解析' - log('warning', f'代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}') - - except Exception as exc: - last_error = f'代理API请求失败: {exc}' - log('warning', f'代理预检 {attempt}/{max_attempts}: {last_error}') - - return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}' +__all__ = [ + "ProxyManager", + "ProxyResolver", + "get_proxy_manager", + "parse_proxy_response", + "resolve_working_proxy", + "verify_proxies_concurrent", + "verify_proxy_url", +] diff --git a/core/douyu/proxy_manager.py b/core/douyu/proxy_manager.py new file mode 100644 index 0000000..d59d273 --- /dev/null +++ b/core/douyu/proxy_manager.py @@ -0,0 +1,165 @@ +"""代理池管理。""" + +import threading +import time +from typing import Optional + +import requests +from loguru import logger + +from .proxy_resolver import ProxyResolver +from .proxy_whitelist import DouyuWhitelistSyncer + + +class ProxyManager: + """代理管理器(带已验证代理池缓存,批次内共享复用)""" + + def __init__(self, api_url: str = "", whitelist_uid: str = "", whitelist_ukey: str = ""): + self.api_url = api_url + self.whitelist_uid = whitelist_uid + self.whitelist_ukey = whitelist_ukey + self.current_proxy: Optional[str] = None + self._cond = threading.Condition() + # 已验证可用的代理池: {proxy_url: validated_timestamp} + self._verified_pool: dict[str, float] = {} + # 正在使用中的代理(取走但未归还),避免并发账号用同一个代理 + self._in_use: set[str] = set() + self._pool_ttl = 90 + self._fetching = False + self._whitelist_syncer = ( + DouyuWhitelistSyncer(whitelist_uid, whitelist_ukey) + if whitelist_uid and whitelist_ukey + else None + ) + + def _pick_from_pool_locked(self) -> Optional[str]: + """从池中取一个未过期且未在使用的代理(调用前需持有锁)。""" + now = time.time() + expired = [proxy for proxy, ts in self._verified_pool.items() if now - ts > self._pool_ttl] + for proxy in expired: + del self._verified_pool[proxy] + self._in_use.discard(proxy) + + for proxy in self._verified_pool: + if proxy not in self._in_use: + self._in_use.add(proxy) + self.current_proxy = proxy + return proxy + + for proxy in self._verified_pool: + self.current_proxy = proxy + return proxy + return None + + def get_proxy(self, max_attempts: int = 5) -> Optional[str]: + """ + 获取可用代理 IP,优先从已验证代理池复用。 + + 线程安全:池空时只有一个线程调 API 获取并验证所有代理入池, + 其他线程等待后复用。 + """ + with self._cond: + proxy = self._pick_from_pool_locked() + if proxy: + logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})") + return proxy + + if self._fetching: + logger.debug("代理池空,等待其他线程获取...") + self._cond.wait(timeout=60) + proxy = self._pick_from_pool_locked() + if proxy: + logger.debug(f"等待后从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})") + return proxy + return None + + self._fetching = True + + try: + proxies_all, msg = self._fetch_and_verify_all(max_attempts) + finally: + with self._cond: + self._fetching = False + self._cond.notify_all() + + if proxies_all: + with self._cond: + now = time.time() + for proxy in proxies_all: + self._verified_pool[proxy] = now + first = proxies_all[0] + self._in_use.add(first) + self.current_proxy = first + logger.info(f"代理池充盈 {len(proxies_all)} 个可用代理: {first} ...") + return first + + logger.warning(f"获取代理失败: {msg}") + return None + + def _fetch_and_verify_all(self, max_attempts: int) -> tuple[Optional[list[str]], str]: + """调代理 API 获取一批代理,并发验证所有可用代理。""" + resolver = ProxyResolver( + api_url=self.api_url, + whitelist_syncer=self._whitelist_syncer, + sync_local_exit_ip=bool(self._whitelist_syncer), + sync_whitelist_once=False, + ) + available, msg = resolver.fetch_verified(max_attempts=max_attempts, return_all=True) + if isinstance(available, list): + return available, msg + if isinstance(available, str): + return [available], msg + return None, msg + + def mark_bad(self, proxy_url: str) -> None: + """标记代理为不可用,从池中移除(极验失败/代理连接失败时调用)。""" + with self._cond: + removed = self._verified_pool.pop(proxy_url, None) + self._in_use.discard(proxy_url) + if self.current_proxy == proxy_url: + self.current_proxy = None + if removed: + logger.info(f"代理标记为不可用并移出池: {proxy_url} (池剩余 {len(self._verified_pool)})") + self._cond.notify_all() + + def release_proxy(self, proxy_url: str) -> None: + """归还代理到池(登录完成后调用,让其他账号可以复用)。""" + with self._cond: + self._in_use.discard(proxy_url) + self._cond.notify_all() + + def get_proxies_dict(self, proxy: str = None) -> dict: + """获取 requests 使用的 proxies 字典。""" + proxy = proxy or self.current_proxy + if proxy: + return {'http': proxy, 'https': proxy} + return {} + + def verify_proxy(self, proxy: str = None) -> bool: + """验证当前代理是否可用。""" + proxy = proxy or self.current_proxy + if not proxy: + return False + try: + response = requests.get( + 'https://httpbin.org/ip', + proxies={'http': proxy, 'https': proxy}, + timeout=10, + ) + if response.status_code == 200: + data = response.json() + logger.info(f"代理验证成功,当前IP: {data.get('origin')}") + return True + return False + except Exception as exc: + logger.error(f"代理验证失败: {exc}") + return False + + +def get_proxy_manager( + api_url: str = "", + whitelist_uid: str = "", + whitelist_ukey: str = "", +) -> ProxyManager: + """获取代理管理器实例。""" + return ProxyManager(api_url, whitelist_uid=whitelist_uid, whitelist_ukey=whitelist_ukey) diff --git a/core/douyu/proxy_parser.py b/core/douyu/proxy_parser.py new file mode 100644 index 0000000..7dd901d --- /dev/null +++ b/core/douyu/proxy_parser.py @@ -0,0 +1,59 @@ +"""代理 API 响应解析。""" + +import json +import re +from typing import Optional + + +def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]: + """ + 解析代理 API 响应,支持 JSON 格式与旧版纯文本格式。 + + Returns: + (proxy_urls, whitelist_ip) + - proxy_urls: 解析到的所有代理地址列表(http://ip:port) + - whitelist_ip: 需要添加到白名单的 IP,无错误时为 None + """ + text = (text or "").strip() + if not text: + return [], None + + try: + data = json.loads(text) + if isinstance(data, dict): + code = data.get("code") + msg = data.get("msg", "") or "" + if code != 0 and ("白名单" in msg or "添加白名单" in msg): + ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg) + if ip_match: + return [], ip_match.group(1) + + data_field = data.get("data") + proxies: list[str] = [] + if isinstance(data_field, list): + for item in data_field: + if not isinstance(item, dict): + continue + ip = item.get("IP") or item.get("ip") + port = item.get("Port") or item.get("port") + if ip and port: + proxies.append(f"http://{ip}:{port}") + if proxies: + return proxies, None + + if code == 0 and not proxies: + return [], None + except (json.JSONDecodeError, ValueError): + pass + + if '添加白名单' in text or '白名单' in text: + ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) + if ip_match: + return [], ip_match.group(1) + + matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) + proxies = [f"http://{ip}:{port}" for ip, port in matches] + if proxies: + return proxies, None + + return [], None diff --git a/core/douyu/proxy_resolver.py b/core/douyu/proxy_resolver.py new file mode 100644 index 0000000..f7239ae --- /dev/null +++ b/core/douyu/proxy_resolver.py @@ -0,0 +1,175 @@ +"""代理获取、白名单同步、可用性验证的统一流程。""" + +import time +from typing import Callable, Optional, Protocol + +import requests +from loguru import logger + +from .proxy_parser import parse_proxy_response +from .proxy_verifier import verify_proxies_concurrent +from .proxy_whitelist import DouyuWhitelistSyncer + +LogFunc = Callable[[str, str], None] + + +class WhitelistSyncer(Protocol): + """代理解析流程需要的白名单能力。""" + + def sync_ip(self, ip: str) -> tuple[bool, str]: + ... + + def get_local_exit_ip(self) -> Optional[str]: + ... + + +class ProxyResolver: + """从代理 API 获取并验证代理,按需同步白名单。""" + + def __init__( + self, + api_url: str, + whitelist_syncer: Optional[WhitelistSyncer] = None, + log_func: Optional[LogFunc] = None, + sync_local_exit_ip: bool = False, + sync_whitelist_once: bool = True, + ): + self.api_url = api_url + self.whitelist_syncer = whitelist_syncer + self.log_func = log_func + self.sync_local_exit_ip = sync_local_exit_ip + self.sync_whitelist_once = sync_whitelist_once + self._last_synced_ip: Optional[str] = None + self._has_synced_whitelist = False + + def _log(self, level: str, message: str) -> None: + if self.log_func: + self.log_func(level, message) + return + log_method = getattr( + logger, + level if level in ('debug', 'info', 'warning', 'error', 'success') else 'info', + logger.info, + ) + log_method(message) + + def _sync_ip(self, ip: str) -> tuple[bool, str]: + if not self.whitelist_syncer: + return False, '未配置白名单 UID/UKEY' + ok, sync_msg = self.whitelist_syncer.sync_ip(ip) + if ok: + self._last_synced_ip = ip + self._has_synced_whitelist = True + return ok, sync_msg + + def _sync_local_exit_ip_if_needed(self, attempt: int) -> None: + if not self.sync_local_exit_ip or not self.whitelist_syncer: + return + + local_ip = self.whitelist_syncer.get_local_exit_ip() + if local_ip and local_ip != self._last_synced_ip: + self._log('info', f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...") + ok, sync_msg = self._sync_ip(local_ip) + if ok: + self._log('info', f"白名单同步成功: {sync_msg}") + time.sleep(2) + else: + self._log('warning', f"白名单同步失败: {sync_msg}") + elif local_ip == self._last_synced_ip: + self._log('debug', f"[尝试 {attempt}] 出口IP未变: {local_ip}") + + def fetch_verified( + self, + max_attempts: int = 4, + return_all: bool = False, + ) -> tuple[Optional[str | list[str]], str]: + """ + 获取并验证代理。 + + Args: + max_attempts: 最大尝试次数 + return_all: True 返回所有可用代理,False 返回第一个可用代理 + """ + last_error = "" + + for attempt in range(1, max_attempts + 1): + if attempt > 1: + delay = min(attempt - 1, 2) + if self.log_func: + self._log('info', f'等待 {delay}s 后重试...') + time.sleep(delay) + + self._sync_local_exit_ip_if_needed(attempt) + self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理') + + try: + response = requests.get(self.api_url, timeout=10) + response.raise_for_status() + text = response.text.strip() + + proxy_urls, whitelist_ip = parse_proxy_response(text) + + if proxy_urls: + self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证') + available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all) + if available: + if not return_all: + self._log('success', f'代理预检成功: {available}') + return available, msg + last_error = msg + self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}") + continue + + if whitelist_ip and self.whitelist_syncer: + if self.sync_whitelist_once and self._has_synced_whitelist: + last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}' + self._log('warning', last_error) + continue + + self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...') + ok, sync_msg = self._sync_ip(whitelist_ip) + self._log('success' if ok else 'error', f'白名单同步: {sync_msg}') + if ok: + self._log('info', '白名单已更新,等待2秒后重试...') + time.sleep(2) + continue + return None, f'白名单同步失败: {sync_msg}' + + last_error = '代理API响应无法解析' + self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}") + + except Exception as exc: + last_error = f'代理API请求失败: {exc}' + self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}") + + return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}' + + +def resolve_working_proxy( + api_url: str, + whitelist_uid: str = "", + whitelist_ukey: str = "", + max_attempts: int = 4, + log_func: Optional[LogFunc] = None, +) -> tuple[Optional[str], str]: + """ + 从代理 API 获取可用代理,自动处理白名单同步。 + + 保留旧函数签名,供 Web 层代理测试继续使用。 + """ + syncer = ( + DouyuWhitelistSyncer(whitelist_uid, whitelist_ukey) + if whitelist_uid and whitelist_ukey + else None + ) + resolver = ProxyResolver( + api_url=api_url, + whitelist_syncer=syncer, + log_func=log_func, + sync_local_exit_ip=False, + sync_whitelist_once=True, + ) + proxy, msg = resolver.fetch_verified(max_attempts=max_attempts, return_all=False) + if isinstance(proxy, list): + return (proxy[0] if proxy else None), msg + return proxy, msg diff --git a/core/douyu/proxy_verifier.py b/core/douyu/proxy_verifier.py new file mode 100644 index 0000000..b0b5e2b --- /dev/null +++ b/core/douyu/proxy_verifier.py @@ -0,0 +1,114 @@ +"""代理可用性验证。""" + +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Optional +import time as _time + +import requests +from loguru import logger + + +def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str]: + """ + 验证代理是否可用,只验证斗鱼主站(登录的最终目标)。 + + Returns: + (是否可用, 消息) + """ + from utils.http_logger import log_http + + proxies = {'http': proxy_url, 'https': proxy_url} + started = _time.monotonic() + try: + response = requests.get( + 'https://www.douyu.com', + proxies=proxies, + timeout=timeout, + headers={'User-Agent': 'Mozilla/5.0'}, + ) + elapsed = _time.monotonic() - started + response.raise_for_status() + log_http( + category="proxy_verify", method="GET", url="https://www.douyu.com", + status_code=response.status_code, response_body=f"代理可用: {proxy_url}", + duration=elapsed, proxy=proxy_url, tag="proxy_verify", + ) + return True, '代理可用 → 斗鱼主站' + except Exception as exc: + elapsed = _time.monotonic() - started + err_msg = str(exc) + if 'Tunnel connection failed' in err_msg or '503' in err_msg: + detail = '代理拒绝连接(白名单可能未生效)' + elif 'timed out' in err_msg.lower(): + detail = '连接超时' + else: + detail = type(exc).__name__ + logger.debug(f"代理验证失败 [{proxy_url}]: {detail}") + log_http( + category="proxy_verify", method="GET", url="https://www.douyu.com", + duration=elapsed, error=f"[{proxy_url}] {detail}: {err_msg[:200]}", + proxy=proxy_url, tag="proxy_verify", + ) + return False, detail + + +def verify_proxies_concurrent( + proxy_urls: list[str], + timeout: tuple = (5, 8), + max_workers: int = 5, + return_all: bool = False, +) -> tuple[Optional[str | list[str]], str]: + """ + 并发验证多个代理 URL。 + + Returns: + return_all=False: (可用代理 URL 或 None, 消息) + return_all=True: (可用代理 URL 列表或 None, 消息) + """ + if not proxy_urls: + return None, '无代理可验证' + + if len(proxy_urls) == 1: + ok, msg = verify_proxy_url(proxy_urls[0], timeout) + if ok: + return (proxy_urls if return_all else proxy_urls[0]), msg + return None, msg + + if return_all: + available: list[str] = [] + with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: + future_map = { + executor.submit(verify_proxy_url, proxy, timeout): proxy + for proxy in proxy_urls + } + for future in as_completed(future_map): + try: + ok, _ = future.result() + if ok: + available.append(future_map[future]) + except Exception: + continue + if available: + logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理") + return available, f'找到 {len(available)} 个可用代理' + return None, f'共 {len(proxy_urls)} 个代理均不可用' + + with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: + future_map = { + executor.submit(verify_proxy_url, proxy, timeout): proxy + for proxy in proxy_urls + } + for future in as_completed(future_map): + proxy_url = future_map[future] + try: + ok, msg = future.result() + if ok: + logger.success(f"并发验证找到可用代理: {proxy_url}") + for item in future_map: + if item != future: + item.cancel() + return proxy_url, msg + except Exception: + continue + + return None, f'共 {len(proxy_urls)} 个代理均不可用' diff --git a/core/douyu/proxy_whitelist.py b/core/douyu/proxy_whitelist.py new file mode 100644 index 0000000..aa641a8 --- /dev/null +++ b/core/douyu/proxy_whitelist.py @@ -0,0 +1,20 @@ +"""代理模块使用的白名单适配器。""" + +from typing import Optional + +from .whitelist import WhitelistManager, get_local_exit_ip + + +class DouyuWhitelistSyncer: + """把白名单 API 隔离成代理解析流程可调用的适配器。""" + + def __init__(self, uid: str, ukey: str): + self.uid = uid + self.ukey = ukey + + def sync_ip(self, ip: str) -> tuple[bool, str]: + manager = WhitelistManager(self.uid, self.ukey) + return manager.sync_ip(ip) + + def get_local_exit_ip(self) -> Optional[str]: + return get_local_exit_ip() diff --git a/pyproject.toml b/pyproject.toml index 94bcc9b..2c67d15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "bcrypt>=4.0.0", "pydantic>=2.0.0", "python-multipart>=0.0.9", + "alembic>=1.18.4", ] [build-system] diff --git a/uv.lock b/uv.lock index 73f7350..cacbb5c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,89 @@ version = 1 revision = 3 requires-python = "==3.12.*" +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -11,6 +94,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -36,6 +142,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -45,29 +163,148 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + [[package]] name = "douyu-login-py" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, + { name = "bcrypt" }, + { name = "fastapi" }, { name = "loguru" }, { name = "numpy" }, { name = "opencv-python-headless" }, { name = "pillow" }, { name = "pycryptodome" }, - { name = "pyyaml" }, - { name = "requests" }, + { name = "pydantic" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "requests", extra = ["socks"] }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.18.4" }, + { name = "bcrypt", specifier = ">=4.0.0" }, + { name = "fastapi", specifier = ">=0.110.0" }, { name = "loguru", specifier = ">=0.7.0" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "opencv-python-headless", specifier = ">=4.8.0" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "pycryptodome", specifier = ">=3.19.0" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "requests", specifier = ">=2.31.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "requests", extras = ["socks"], specifier = ">=2.31.0" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "fastapi" +version = "0.138.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" }, + { url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" }, + { url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" }, + { url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" }, + { url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, ] [[package]] @@ -92,6 +329,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -148,6 +416,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -167,6 +453,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -200,6 +577,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -209,6 +666,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "win32-setctime" version = "1.2.0" diff --git a/web/backend/database.py b/web/backend/database.py index 5348caf..2c86125 100644 --- a/web/backend/database.py +++ b/web/backend/database.py @@ -5,12 +5,16 @@ from pathlib import Path from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base -DB_PATH = Path(__file__).parent.parent.parent / "data" / "web.db" +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DB_PATH = PROJECT_ROOT / "data" / "web.db" DB_PATH.parent.mkdir(parents=True, exist_ok=True) +DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}") + +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} engine = create_engine( - f"sqlite:///{DB_PATH}", - connect_args={"check_same_thread": False}, + DATABASE_URL, + connect_args=connect_args, echo=False, ) @@ -28,49 +32,20 @@ def get_db(): def init_db(): - """建表 + 写入初始数据。""" - Base.metadata.create_all(bind=engine) - _migrate() + """执行数据库迁移 + 写入初始数据。""" + run_migrations() _seed() -def _migrate(): - """数据库迁移:为已有表添加新列。""" - from sqlalchemy import text - with engine.connect() as conn: - # 检查 accounts.tag 列是否存在 - result = conn.execute(text("PRAGMA table_info(accounts)")) - columns = [row[1] for row in result] - if 'tag' not in columns: - conn.execute(text("ALTER TABLE accounts ADD COLUMN tag VARCHAR(64) DEFAULT ''")) - conn.commit() +def run_migrations(): + """运行 Alembic 迁移到最新版本。""" + from alembic import command + from alembic.config import Config - # 检查 users.custom_permissions 列是否存在 - result = conn.execute(text("PRAGMA table_info(users)")) - columns = [row[1] for row in result] - if 'custom_permissions' not in columns: - conn.execute(text("ALTER TABLE users ADD COLUMN custom_permissions JSON DEFAULT NULL")) - conn.commit() - - # 检查 accounts.email_imap_ssl 列是否存在 - result = conn.execute(text("PRAGMA table_info(accounts)")) - columns = [row[1] for row in result] - if 'email_imap_ssl' not in columns: - # 旧数据端口993的默认True(SSL),其他端口默认False - conn.execute(text("ALTER TABLE accounts ADD COLUMN email_imap_ssl BOOLEAN DEFAULT 1")) - conn.commit() - # 修正旧数据中使用 111.229.206.54 的账号:端口改为143,SSL改为False - conn.execute(text( - "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " - "WHERE email_imap_server = '111.229.206.54'" - )) - conn.commit() - # 修正旧数据中使用 mail.bdhg.xyz 的账号:993端口不通,改用143非SSL - conn.execute(text( - "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " - "WHERE email_imap_server = 'mail.bdhg.xyz'" - )) - conn.commit() + config = Config(str(PROJECT_ROOT / "alembic.ini")) + config.set_main_option("script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")) + config.set_main_option("sqlalchemy.url", DATABASE_URL) + command.upgrade(config, "head") def _seed(): diff --git a/web/backend/migrations/README.md b/web/backend/migrations/README.md new file mode 100644 index 0000000..e1a6bcd --- /dev/null +++ b/web/backend/migrations/README.md @@ -0,0 +1,12 @@ +# 数据库迁移 + +本目录由 Alembic 管理 Web 后台数据库结构。 + +常用命令: + +```bash +uv run alembic upgrade head +uv run alembic revision -m "描述" +``` + +应用启动时会自动执行 `upgrade head`,本地开发通常不需要手动运行迁移命令。 diff --git a/web/backend/migrations/env.py b/web/backend/migrations/env.py new file mode 100644 index 0000000..ea53746 --- /dev/null +++ b/web/backend/migrations/env.py @@ -0,0 +1,57 @@ +"""Alembic 迁移环境。""" + +from logging.config import fileConfig +from pathlib import Path +import sys + +from alembic import context +from sqlalchemy import engine_from_config, pool + +ROOT_DIR = Path(__file__).resolve().parents[3] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from web.backend.database import Base, DATABASE_URL # noqa: E402 +from web.backend import models # noqa: F401,E402 + +config = context.config +config.set_main_option("sqlalchemy.url", DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """离线模式生成 SQL。""" + context.configure( + url=DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """在线模式直接执行迁移。""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/web/backend/migrations/script.py.mako b/web/backend/migrations/script.py.mako new file mode 100644 index 0000000..1ba49a8 --- /dev/null +++ b/web/backend/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/web/backend/migrations/versions/20260623_0001_initial_web_schema.py b/web/backend/migrations/versions/20260623_0001_initial_web_schema.py new file mode 100644 index 0000000..5b4bc97 --- /dev/null +++ b/web/backend/migrations/versions/20260623_0001_initial_web_schema.py @@ -0,0 +1,160 @@ +"""初始化 Web 后台数据库结构 + +Revision ID: 20260623_0001 +Revises: +Create Date: 2026-06-23 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "20260623_0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _columns(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {column["name"] for column in sa.inspect(bind).get_columns(table_name)} + + +def _indexes(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} + + +def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool: + if column.name in _columns(bind, table_name): + return False + op.add_column(table_name, column) + return True + + +def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: + if name not in _indexes(bind, table_name): + op.create_index(name, table_name, columns, unique=unique) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _has_table(bind, "users"): + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("password_hash", sa.String(length=256), nullable=False), + sa.Column("role", sa.String(length=32), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("custom_permissions", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + else: + _add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True)) + _create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True) + + if not _has_table(bind, "accounts"): + op.create_table( + "accounts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=128), nullable=False), + sa.Column("password", sa.String(length=256), nullable=False), + sa.Column("email", sa.String(length=128), nullable=False), + sa.Column("email_password", sa.String(length=256), nullable=False), + sa.Column("email_imap_server", sa.String(length=128), nullable=True), + sa.Column("email_imap_port", sa.Integer(), nullable=True), + sa.Column("email_imap_ssl", sa.Boolean(), nullable=True), + sa.Column("assigned_to", sa.Integer(), nullable=True), + sa.Column("tag", sa.String(length=64), nullable=True), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["assigned_to"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + else: + _add_column_if_missing(bind, "accounts", sa.Column("tag", sa.String(length=64), server_default="")) + added_ssl = _add_column_if_missing(bind, "accounts", sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1"))) + if added_ssl: + op.execute( + "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " + "WHERE email_imap_server = '111.229.206.54'" + ) + op.execute( + "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " + "WHERE email_imap_server = 'mail.bdhg.xyz'" + ) + _create_index_if_missing(bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"]) + + if not _has_table(bind, "proxy_config"): + op.create_table( + "proxy_config", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.Column("api_url", sa.String(length=512), nullable=True), + sa.Column("http", sa.String(length=256), nullable=True), + sa.Column("https", sa.String(length=256), nullable=True), + sa.Column("whitelist_enabled", sa.Boolean(), nullable=True), + sa.Column("whitelist_uid", sa.String(length=64), nullable=True), + sa.Column("whitelist_ukey", sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + if not _has_table(bind, "login_tasks"): + op.create_table( + "login_tasks", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("batch_id", sa.String(length=64), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("cookie", sa.Text(), nullable=True), + sa.Column("message", sa.String(length=512), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + _create_index_if_missing(bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"]) + + if not _has_table(bind, "audit_logs"): + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("username", sa.String(length=64), nullable=True), + sa.Column("action", sa.String(length=128), nullable=False), + sa.Column("target", sa.String(length=256), nullable=True), + sa.Column("detail", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "audit_logs"): + op.drop_table("audit_logs") + if _has_table(bind, "login_tasks"): + op.drop_index("ix_login_tasks_batch_id", table_name="login_tasks") + op.drop_table("login_tasks") + if _has_table(bind, "proxy_config"): + op.drop_table("proxy_config") + if _has_table(bind, "accounts"): + op.drop_index("ix_accounts_assigned_to", table_name="accounts") + op.drop_table("accounts") + if _has_table(bind, "users"): + op.drop_index("ix_users_username", table_name="users") + op.drop_table("users") diff --git a/web/backend/services/account_service.py b/web/backend/services/account_service.py index 94d99b3..cdc1719 100644 --- a/web/backend/services/account_service.py +++ b/web/backend/services/account_service.py @@ -3,11 +3,9 @@ import csv import re -from sqlalchemy import func -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session -from ..models import Account, AuditLog, LoginTask -from ..permissions import user_has_permission +from ..models import Account, LoginTask EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$') diff --git a/web/backend/services/proxy_service.py b/web/backend/services/proxy_service.py index 8bd4cd2..3228ffe 100644 --- a/web/backend/services/proxy_service.py +++ b/web/backend/services/proxy_service.py @@ -6,11 +6,9 @@ import threading import uuid from typing import Optional -import requests as req_lib from sqlalchemy.orm import Session from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response -from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy from ..models import ProxyConfig as ProxyConfigModel, AuditLog @@ -146,6 +144,8 @@ class ProxyService: loop: asyncio.AbstractEventLoop, ): """在线程中执行白名单测试。""" + import requests as req_lib + from core.douyu.whitelist import WhitelistManager def push(level, message): asyncio.run_coroutine_threadsafe( diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts new file mode 100644 index 0000000..1854ec5 --- /dev/null +++ b/web/frontend/src/api/accounts.ts @@ -0,0 +1,28 @@ +import api from './client'; +import type { + AccountItem, + AssignmentsSummary, + MessageCountResponse, + MessageDeletedResponse, + MessageResponse, +} from './types'; + +export const accountApi = { + list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) => + api.get('/accounts', { params }), + import: (text: string) => api.post('/accounts/import', { text }), + assign: (id: number, assigned_to: number | null) => + api.put(`/accounts/${id}/assign`, { assigned_to }), + batchAssign: (account_ids: number[], assigned_to: number | null) => + api.post('/accounts/batch-assign', { account_ids, assigned_to }), + assignmentsSummary: () => + api.get('/accounts/assignments/summary'), + setTag: (id: number, tag: string) => + api.put(`/accounts/${id}/tag`, { tag }), + batchTag: (account_ids: number[], tag: string) => + api.put('/accounts/batch-tag', { account_ids, tag }), + listTags: () => api.get('/accounts/tags/list'), + delete: (id: number) => api.delete(`/accounts/${id}`), + batchDelete: (account_ids: number[]) => + api.delete('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }), +}; diff --git a/web/frontend/src/api/auth.ts b/web/frontend/src/api/auth.ts new file mode 100644 index 0000000..92c3a52 --- /dev/null +++ b/web/frontend/src/api/auth.ts @@ -0,0 +1,11 @@ +import api from './client'; +import type { CurrentUser, LoginResult, MessageResponse } from './types'; + +export const authApi = { + login: (username: string, password: string) => + api.post('/auth/login', { username, password }), + + me: () => api.get('/auth/me'), + + logout: () => api.post('/auth/logout'), +}; diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts new file mode 100644 index 0000000..076b0c2 --- /dev/null +++ b/web/frontend/src/api/client.ts @@ -0,0 +1,22 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, + withCredentials: true, // 携带 httpOnly cookie +}); + +// 响应拦截:统一错误处理 +api.interceptors.response.use( + (response) => response.data, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('user'); + window.location.href = '/login'; + } + const msg = error.response?.data?.detail || error.message || '请求失败'; + return Promise.reject(new Error(msg)); + } +); + +export default api; diff --git a/web/frontend/src/api/cookies.ts b/web/frontend/src/api/cookies.ts new file mode 100644 index 0000000..7f68e0f --- /dev/null +++ b/web/frontend/src/api/cookies.ts @@ -0,0 +1,8 @@ +import api from './client'; +import type { CookieItem, MessageResponse } from './types'; + +export const cookieApi = { + list: () => api.get('/cookies'), + exportCsv: (format?: string) => api.get('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }), + delete: (id: number) => api.delete(`/cookies/${id}`), +}; diff --git a/web/frontend/src/api/index.ts b/web/frontend/src/api/index.ts index 076b0c2..3cc01c6 100644 --- a/web/frontend/src/api/index.ts +++ b/web/frontend/src/api/index.ts @@ -1,22 +1 @@ -import axios from 'axios'; - -const api = axios.create({ - baseURL: '/api', - timeout: 30000, - withCredentials: true, // 携带 httpOnly cookie -}); - -// 响应拦截:统一错误处理 -api.interceptors.response.use( - (response) => response.data, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('user'); - window.location.href = '/login'; - } - const msg = error.response?.data?.detail || error.message || '请求失败'; - return Promise.reject(new Error(msg)); - } -); - -export default api; +export { default } from './client'; diff --git a/web/frontend/src/api/login.ts b/web/frontend/src/api/login.ts new file mode 100644 index 0000000..220a032 --- /dev/null +++ b/web/frontend/src/api/login.ts @@ -0,0 +1,12 @@ +import api from './client'; +import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse } from './types'; + +export const loginApi = { + createBatch: (account_ids: number[], max_geetest_retries?: number, concurrency?: number, max_proxy_retries?: number) => + api.post('/login/batch', { account_ids, max_geetest_retries, concurrency, max_proxy_retries }), + listTasks: (batch_id?: string) => + api.get('/login/tasks', { params: batch_id ? { batch_id } : {} }), + stop: (batch_id: string) => api.post(`/login/stop/${batch_id}`), + deleteTask: (id: number) => api.delete(`/login/tasks/${id}`), + deleteTasks: (ids: number[]) => api.delete(`/login/tasks`, { params: { task_ids: ids.join(',') } }), +}; diff --git a/web/frontend/src/api/logs.ts b/web/frontend/src/api/logs.ts new file mode 100644 index 0000000..29db8f2 --- /dev/null +++ b/web/frontend/src/api/logs.ts @@ -0,0 +1,8 @@ +import api from './client'; +import type { HttpLogClearResult, HttpLogListResult } from './types'; + +export const logApi = { + listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) => + api.get('/logs/http', { params }), + clearHttp: () => api.delete('/logs/http'), +}; diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 1a16934..5669239 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -1,228 +1,8 @@ -import api from './index'; - -// ==================== 通用响应类型 ==================== - -interface MessageResponse { - message: string; - success: boolean; -} - -interface MessageCountResponse extends MessageResponse { - count: number; -} - -interface MessageDeletedResponse extends MessageResponse { - deleted: number; -} - -// ==================== Auth ==================== - -export interface LoginResult { - access_token: string; - token_type: string; - role: string; - username: string; - permissions: string[]; -} - -export interface CurrentUser { - id: number; - username: string; - role: string; - role_label: string; - is_active: boolean; - remark: string | null; - permissions: string[]; -} - -export interface UserInfo { - id: number; - username: string; - role: string; - is_active: boolean; - remark: string; - permissions: string[]; - custom_permissions?: string[] | null; -} - -// ==================== Account ==================== - -export interface AccountItem { - id: number; - username: string; - password?: string | null; - email?: string | null; - email_password?: string | null; - tag: string; - assigned_to: number | null; - assigned_username: string | null; - remark: string; - created_at: string | null; -} - -export interface AssignmentsSummary { - support_users: SupportUserItem[]; - unassigned_count: number; -} - -export interface SupportUserItem { - id: number; - username: string; - assigned_count: number; -} - -// ==================== Login Task ==================== - -export interface LoginTaskItem { - id: number; - batch_id: string; - account_id: number; - account_username: string; - status: string; - cookie: string; - message: string; - created_by: number; - created_at: string | null; - finished_at: string | null; -} - -export interface BatchLoginResult { - batch_id: string; - count: number; - success: boolean; -} - -// ==================== Cookie ==================== - -export interface CookieItem { - id: number; - batch_id: string; - account_id: number; - account_username: string; - assigned_to: number | null; - assigned_username: string | null; - created_at: string | null; - cookie: string; - cookie_preview: string; -} - -// ==================== Proxy ==================== - -export interface ProxyConfig { - enabled: boolean; - api_url: string; - http: string; - https: string; - whitelist_enabled: boolean; - whitelist_uid: string; - whitelist_ukey: string; -} - -export interface ProxyTestResult { - test_id: string; - success: boolean; -} - -// ==================== Log ==================== - -export interface HttpLogEntry { - timestamp: string; - ts: number; - category: string; - tag: string; - method: string; - url: string; - proxy: string | null; - request: { headers: Record; body: string }; - response: { status_code: number | null; headers: Record; body: string }; - duration_ms: number | null; - error: string | null; - level: string; -} - -export interface HttpLogListResult { - items: HttpLogEntry[]; - total: number; -} - -export interface HttpLogClearResult { - success: boolean; - cleared: number; -} - -// ==================== Permissions ==================== - -export interface PermissionsListResult { - permissions: Record; - role_permissions: Record; -} - -// ==================== API 定义 ==================== - -export const authApi = { - login: (username: string, password: string) => - api.post('/auth/login', { username, password }), - - me: () => api.get('/auth/me'), - - logout: () => api.post('/auth/logout'), -}; - -export const userApi = { - list: () => api.get('/users'), - create: (data: { username: string; password: string; role: string; remark?: string }) => - api.post('/users', data), - update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) => - api.put(`/users/${id}`, data), - delete: (id: number) => api.delete(`/users/${id}`), - listPermissions: () => api.get('/users/permissions/list'), -}; - -export const accountApi = { - list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) => - api.get('/accounts', { params }), - import: (text: string) => api.post('/accounts/import', { text }), - assign: (id: number, assigned_to: number | null) => - api.put(`/accounts/${id}/assign`, { assigned_to }), - batchAssign: (account_ids: number[], assigned_to: number | null) => - api.post('/accounts/batch-assign', { account_ids, assigned_to }), - assignmentsSummary: () => - api.get('/accounts/assignments/summary'), - setTag: (id: number, tag: string) => - api.put(`/accounts/${id}/tag`, { tag }), - batchTag: (account_ids: number[], tag: string) => - api.put('/accounts/batch-tag', { account_ids, tag }), - listTags: () => api.get('/accounts/tags/list'), - delete: (id: number) => api.delete(`/accounts/${id}`), - batchDelete: (account_ids: number[]) => - api.delete('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }), -}; - -export const loginApi = { - createBatch: (account_ids: number[], max_geetest_retries?: number, concurrency?: number, max_proxy_retries?: number) => - api.post('/login/batch', { account_ids, max_geetest_retries, concurrency, max_proxy_retries }), - listTasks: (batch_id?: string) => - api.get('/login/tasks', { params: batch_id ? { batch_id } : {} }), - stop: (batch_id: string) => api.post(`/login/stop/${batch_id}`), - deleteTask: (id: number) => api.delete(`/login/tasks/${id}`), - deleteTasks: (ids: number[]) => api.delete(`/login/tasks`, { params: { task_ids: ids.join(',') } }), -}; - -export const cookieApi = { - list: () => api.get('/cookies'), - exportCsv: (format?: string) => api.get('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }), - delete: (id: number) => api.delete(`/cookies/${id}`), -}; - -export const proxyApi = { - get: () => api.get('/proxy'), - update: (data: ProxyConfig) => api.put('/proxy', data), - test: () => api.post('/proxy/test'), - testWhitelist: () => api.post('/proxy/whitelist/test'), -}; - -export const logApi = { - listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) => - api.get('/logs/http', { params }), - clearHttp: () => api.delete('/logs/http'), -}; +export * from './types'; +export { accountApi } from './accounts'; +export { authApi } from './auth'; +export { cookieApi } from './cookies'; +export { logApi } from './logs'; +export { loginApi } from './login'; +export { proxyApi } from './proxy'; +export { userApi } from './users'; diff --git a/web/frontend/src/api/proxy.ts b/web/frontend/src/api/proxy.ts new file mode 100644 index 0000000..cfe797c --- /dev/null +++ b/web/frontend/src/api/proxy.ts @@ -0,0 +1,9 @@ +import api from './client'; +import type { ProxyConfig, ProxyTestResult } from './types'; + +export const proxyApi = { + get: () => api.get('/proxy'), + update: (data: ProxyConfig) => api.put('/proxy', data), + test: () => api.post('/proxy/test'), + testWhitelist: () => api.post('/proxy/whitelist/test'), +}; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts new file mode 100644 index 0000000..4a772dd --- /dev/null +++ b/web/frontend/src/api/types.ts @@ -0,0 +1,156 @@ +// ==================== 通用响应类型 ==================== + +export interface MessageResponse { + message: string; + success: boolean; +} + +export interface MessageCountResponse extends MessageResponse { + count: number; +} + +export interface MessageDeletedResponse extends MessageResponse { + deleted: number; +} + +// ==================== Auth ==================== + +export interface LoginResult { + access_token: string; + token_type: string; + role: string; + username: string; + permissions: string[]; +} + +export interface CurrentUser { + id: number; + username: string; + role: string; + role_label: string; + is_active: boolean; + remark: string | null; + permissions: string[]; +} + +export interface UserInfo { + id: number; + username: string; + role: string; + is_active: boolean; + remark: string; + permissions: string[]; + custom_permissions?: string[] | null; +} + +// ==================== Account ==================== + +export interface AccountItem { + id: number; + username: string; + password?: string | null; + email?: string | null; + email_password?: string | null; + tag: string; + assigned_to: number | null; + assigned_username: string | null; + remark: string; + created_at: string | null; +} + +export interface AssignmentsSummary { + support_users: SupportUserItem[]; + unassigned_count: number; +} + +export interface SupportUserItem { + id: number; + username: string; + assigned_count: number; +} + +// ==================== Login Task ==================== + +export interface LoginTaskItem { + id: number; + batch_id: string; + account_id: number; + account_username: string; + status: string; + cookie: string; + message: string; + created_by: number; + created_at: string | null; + finished_at: string | null; +} + +export interface BatchLoginResult { + batch_id: string; + count: number; + success: boolean; +} + +// ==================== Cookie ==================== + +export interface CookieItem { + id: number; + batch_id: string; + account_id: number; + account_username: string; + assigned_to: number | null; + assigned_username: string | null; + created_at: string | null; + cookie: string; + cookie_preview: string; +} + +// ==================== Proxy ==================== + +export interface ProxyConfig { + enabled: boolean; + api_url: string; + http: string; + https: string; + whitelist_enabled: boolean; + whitelist_uid: string; + whitelist_ukey: string; +} + +export interface ProxyTestResult { + test_id: string; + success: boolean; +} + +// ==================== Log ==================== + +export interface HttpLogEntry { + timestamp: string; + ts: number; + category: string; + tag: string; + method: string; + url: string; + proxy: string | null; + request: { headers: Record; body: string }; + response: { status_code: number | null; headers: Record; body: string }; + duration_ms: number | null; + error: string | null; + level: string; +} + +export interface HttpLogListResult { + items: HttpLogEntry[]; + total: number; +} + +export interface HttpLogClearResult { + success: boolean; + cleared: number; +} + +// ==================== Permissions ==================== + +export interface PermissionsListResult { + permissions: Record; + role_permissions: Record; +} diff --git a/web/frontend/src/api/users.ts b/web/frontend/src/api/users.ts new file mode 100644 index 0000000..6525e60 --- /dev/null +++ b/web/frontend/src/api/users.ts @@ -0,0 +1,12 @@ +import api from './client'; +import type { MessageResponse, PermissionsListResult, UserInfo } from './types'; + +export const userApi = { + list: () => api.get('/users'), + create: (data: { username: string; password: string; role: string; remark?: string }) => + api.post('/users', data), + update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) => + api.put(`/users/${id}`, data), + delete: (id: number) => api.delete(`/users/${id}`), + listPermissions: () => api.get('/users/permissions/list'), +}; diff --git a/web/frontend/src/components/RealtimeLogPanel.tsx b/web/frontend/src/components/RealtimeLogPanel.tsx new file mode 100644 index 0000000..9e17c37 --- /dev/null +++ b/web/frontend/src/components/RealtimeLogPanel.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { Card, Spin, Tag, theme } from 'antd'; +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import type { RealtimeLog } from '../hooks/useWebSocketLogs'; + +interface RealtimeLogPanelProps { + logs: RealtimeLog[]; + connected?: boolean; + title?: string; + emptyText?: ReactNode; + height?: number | string; + mode?: 'inline' | 'card'; + collapsible?: boolean; + defaultVisible?: boolean; + spinWhenEmpty?: boolean; + style?: CSSProperties; + bodyStyle?: CSSProperties; +} + +export default function RealtimeLogPanel({ + logs, + connected = false, + title = '实时日志', + emptyText = '暂无日志', + height = '20vh', + mode = 'inline', + collapsible = false, + defaultVisible = true, + spinWhenEmpty = false, + style, + bodyStyle, +}: RealtimeLogPanelProps) { + const { token } = theme.useToken(); + const [visible, setVisible] = useState(defaultVisible); + const endRef = useRef(null); + + useEffect(() => { + if (visible) { + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs, visible]); + + const logColors: Record = { + error: token.colorError, + success: token.colorSuccess, + warning: token.colorWarning, + info: token.colorText, + }; + + const logBody = ( +
+ {logs.length === 0 ? ( + spinWhenEmpty && connected ? ( + + ) : ( + {emptyText} + ) + ) : ( + logs.map((log, index) => ( +
+ {log.message} +
+ )) + )} +
+
+ ); + + if (mode === 'card') { + return ( + + {logBody} + + ); + } + + return ( +
+
collapsible && setVisible((value) => !value)} + > + {title} + {collapsible && ( + visible ? : + )} + {logs.length > 0 && {logs.length} 条} + {connected && 连接中} +
+ {visible && logBody} +
+ ); +} diff --git a/web/frontend/src/hooks/usePermissions.ts b/web/frontend/src/hooks/usePermissions.ts new file mode 100644 index 0000000..7878e31 --- /dev/null +++ b/web/frontend/src/hooks/usePermissions.ts @@ -0,0 +1,23 @@ +import { useCallback, useMemo } from 'react'; +import { getUser, type AuthUser } from '../store/auth'; + +export function usePermissions(userOverride?: AuthUser | null) { + const user = userOverride === undefined ? getUser() : userOverride; + const permissions = useMemo(() => user?.permissions ?? [], [user]); + + const permissionSet = useMemo(() => new Set(permissions), [permissions]); + + const can = useCallback((permission: string) => permissionSet.has(permission), [permissionSet]); + + const canAny = useCallback( + (items: string[]) => items.some((permission) => permissionSet.has(permission)), + [permissionSet], + ); + + return { + user, + can, + canAny, + permissions, + }; +} diff --git a/web/frontend/src/hooks/useWebSocketLogs.ts b/web/frontend/src/hooks/useWebSocketLogs.ts new file mode 100644 index 0000000..a0db087 --- /dev/null +++ b/web/frontend/src/hooks/useWebSocketLogs.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface RealtimeLog { + level: string; + message: string; +} + +interface ConnectOptions { + clear?: boolean; + onClose?: () => void; + onError?: () => void; + onResult?: () => void; +} + +function toWebSocketUrl(pathOrUrl: string): string { + if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) { + return pathOrUrl; + } + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`; + return `${protocol}://${window.location.host}${path}`; +} + +export function useWebSocketLogs() { + const [logs, setLogs] = useState([]); + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + const callbacksRef = useRef({}); + const suppressCloseRef = useRef(false); + + const clearLogs = useCallback(() => { + setLogs([]); + }, []); + + const close = useCallback((notify = false) => { + if (!wsRef.current) { + setConnected(false); + return; + } + suppressCloseRef.current = !notify; + wsRef.current.close(); + wsRef.current = null; + setConnected(false); + }, []); + + const connect = useCallback((pathOrUrl: string, options: ConnectOptions = {}) => { + close(false); + suppressCloseRef.current = false; + callbacksRef.current = options; + if (options.clear ?? true) { + setLogs([]); + } + + const ws = new WebSocket(toWebSocketUrl(pathOrUrl)); + wsRef.current = ws; + setConnected(true); + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) as RealtimeLog; + if (msg.level === 'heartbeat') return; + if (msg.level === 'result') { + callbacksRef.current.onResult?.(); + return; + } + setLogs((prev) => [...prev, msg]); + } catch { + setLogs((prev) => [...prev, { level: 'info', message: String(event.data) }]); + } + }; + + ws.onclose = () => { + const isCurrent = wsRef.current === ws; + const shouldNotify = isCurrent && !suppressCloseRef.current; + if (isCurrent) { + wsRef.current = null; + setConnected(false); + suppressCloseRef.current = false; + } + if (shouldNotify) { + callbacksRef.current.onClose?.(); + } + }; + + ws.onerror = () => { + setConnected(false); + callbacksRef.current.onError?.(); + }; + }, [close]); + + useEffect(() => () => { + close(false); + }, [close]); + + return { + logs, + connected, + clearLogs, + connect, + close, + }; +} diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 1238c58..a27ecfc 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -7,9 +7,10 @@ import { SunOutlined, MoonOutlined, DesktopOutlined, FileTextOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; -import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth'; +import { getUser, clearAuth, type AuthUser } from '../store/auth'; import { authApi } from '../api/modules'; import { useTheme, type ThemeMode } from '../store/theme'; +import { usePermissions } from '../hooks/usePermissions'; const { Sider, Content } = Layout; const { Text } = Typography; @@ -32,6 +33,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { const [user] = useState(getUser()); const [collapsed, setCollapsed] = useState(false); const { mode, isDark, setMode } = useTheme(); + const { can, canAny } = usePermissions(user); useEffect(() => { if (!user) navigate('/login'); @@ -45,37 +47,37 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { menuItems.push({ key: '/', label: '概览', icon: }); // 账号管理 - if (hasPerm(user, 'account:view_all') || hasPerm(user, 'account:view_assigned')) { + if (canAny(['account:view_all', 'account:view_assigned'])) { menuItems.push({ key: '/accounts', label: '账号管理', icon: }); } // 分配管理 - if (hasPerm(user, 'account:assign')) { + if (can('account:assign')) { menuItems.push({ key: '/assignments', label: '分配管理', icon: }); } // 登录任务 - if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_all')) { + if (canAny(['login:batch', 'login:view_all'])) { menuItems.push({ key: '/login-tasks', label: '登录任务', icon: }); } // Cookie 管理 - if (hasPerm(user, 'cookie:view')) { + if (can('cookie:view')) { menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: }); } // 代理配置 - if (hasPerm(user, 'proxy:manage')) { + if (can('proxy:manage')) { menuItems.push({ key: '/proxy', label: '代理配置', icon: }); } // 请求日志(运营和管理员可见) - if (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch')) { + if (canAny(['audit:view', 'login:batch'])) { menuItems.push({ key: '/http-logs', label: '请求日志', icon: }); } // 用户管理 - if (hasPerm(user, 'user:view')) { + if (can('user:view')) { menuItems.push({ key: '/users', label: '用户管理', icon: }); } @@ -90,7 +92,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { onOk: async () => { try { await authApi.logout(); - } catch {} + } catch { + // 忽略服务端登出失败,仍继续清理本地登录态。 + } clearAuth(); onLogout?.(); navigate('/login', { replace: true }); diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 84c43d8..aae77e6 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -3,9 +3,10 @@ import { Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space, Row, Col, Card, Statistic, } from 'antd'; +import type { TableProps } from 'antd'; import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons'; import { accountApi, userApi, type AccountItem, type UserInfo } from '../api/modules'; -import { getUser, hasPerm } from '../store/auth'; +import { usePermissions } from '../hooks/usePermissions'; import { getErrorMessage } from '../utils/error'; const { TextArea } = Input; @@ -27,12 +28,12 @@ export default function AccountsPage() { const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchTagInput, setBatchTagInput] = useState(''); const [batchTagVisible, setBatchTagVisible] = useState(false); - const user = getUser(); + const { can } = usePermissions(); - const canViewAll = hasPerm(user, 'account:view_all'); - const canImport = hasPerm(user, 'account:import'); - const canAssign = hasPerm(user, 'account:assign'); - const canDelete = hasPerm(user, 'account:delete'); + const canViewAll = can('account:view_all'); + const canImport = can('account:import'); + const canAssign = can('account:assign'); + const canDelete = can('account:delete'); const loadAccounts = async () => { setLoading(true); @@ -52,14 +53,18 @@ export default function AccountsPage() { try { const data = await userApi.list(); setUsers(data.filter((u) => u.role === 'support')); - } catch {} + } catch { + // 忽略客服列表加载失败,账号列表仍可继续使用。 + } }; const loadTags = async () => { try { const data = await accountApi.listTags(); setTags(data); - } catch {} + } catch { + // 忽略标签加载失败,页面会退化为无标签筛选。 + } }; useEffect(() => { @@ -167,7 +172,7 @@ export default function AccountsPage() { } }; - const columns = [ + const columns: TableProps['columns'] = [ { title: 'ID', dataIndex: 'id', width: 60 }, { title: '用户名', dataIndex: 'username' }, { diff --git a/web/frontend/src/pages/AssignmentsPage.tsx b/web/frontend/src/pages/AssignmentsPage.tsx index 9bf13ea..40484e5 100644 --- a/web/frontend/src/pages/AssignmentsPage.tsx +++ b/web/frontend/src/pages/AssignmentsPage.tsx @@ -30,7 +30,7 @@ export default function AssignmentsPage() { try { const data = await accountApi.assignmentsSummary(); setSupportUsers(data.support_users); - return data.support_users as SupportUser[]; + return data.support_users; } catch (e: unknown) { message.error(getErrorMessage(e)); return []; @@ -54,7 +54,7 @@ export default function AssignmentsPage() { const users = await loadSummary(); if (users.length > 0) { const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER); - const savedUser = savedId ? users.find((u: SupportUserItemItem) => u.id === Number(savedId)) : null; + const savedUser = savedId ? users.find((u: SupportUserItem) => u.id === Number(savedId)) : null; setSelectedUser(savedUser || users[0]); } }; @@ -394,4 +394,4 @@ export default function AssignmentsPage() {
); -} \ No newline at end of file +} diff --git a/web/frontend/src/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx index 3693c94..9718d6b 100644 --- a/web/frontend/src/pages/CookiePage.tsx +++ b/web/frontend/src/pages/CookiePage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown } from 'antd'; import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons'; import { cookieApi, type CookieItem } from '../api/modules'; -import { getUser, hasPerm } from '../store/auth'; +import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -14,10 +14,10 @@ export default function CookiePage() { const [loading, setLoading] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [searchText, setSearchText] = useState(''); - const user = getUser(); + const { can } = usePermissions(); - const canView = hasPerm(user, 'cookie:view'); - const canExport = hasPerm(user, 'cookie:export'); + const canView = can('cookie:view'); + const canExport = can('cookie:export'); const loadCookies = async () => { setLoading(true); diff --git a/web/frontend/src/pages/HttpLogsPage.tsx b/web/frontend/src/pages/HttpLogsPage.tsx index f669895..42ee2ae 100644 --- a/web/frontend/src/pages/HttpLogsPage.tsx +++ b/web/frontend/src/pages/HttpLogsPage.tsx @@ -7,7 +7,7 @@ import { ReloadOutlined, DeleteOutlined, SearchOutlined, EyeOutlined, } from '@ant-design/icons'; import { logApi, type HttpLogEntry } from '../api/modules'; -import { hasPerm, getUser } from '../store/auth'; +import { usePermissions } from '../hooks/usePermissions'; import { getErrorMessage } from '../utils/error'; const { Text, Paragraph } = Typography; @@ -35,7 +35,7 @@ export default function HttpLogsPage() { const [level, setLevel] = useState(undefined); const [keyword, setKeyword] = useState(''); const [detailEntry, setDetailEntry] = useState(null); - const user = getUser(); + const { canAny } = usePermissions(); const fetchLogs = useCallback(async () => { setLoading(true); @@ -78,7 +78,7 @@ export default function HttpLogsPage() { } }; - const canManage = user && (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch')); + const canManage = canAny(['audit:view', 'login:batch']); const columns = [ { diff --git a/web/frontend/src/pages/LoginTasksPage.tsx b/web/frontend/src/pages/LoginTasksPage.tsx index 6205623..3247339 100644 --- a/web/frontend/src/pages/LoginTasksPage.tsx +++ b/web/frontend/src/pages/LoginTasksPage.tsx @@ -1,10 +1,12 @@ -import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { - Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm, theme, + Table, Button, Select, message, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, } from 'antd'; -import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DownOutlined, UpOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons'; +import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons'; import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules'; -import { getUser, hasPerm } from '../store/auth'; +import RealtimeLogPanel from '../components/RealtimeLogPanel'; +import { usePermissions } from '../hooks/usePermissions'; +import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -35,20 +37,16 @@ export default function LoginTasksPage() { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(false); const [batchId, setBatchId] = useState(null); - const [logs, setLogs] = useState<{ level: string; message: string }[]>([]); - const [wsConnected, setWsConnected] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [concurrency, setConcurrency] = useState(3); const [maxProxyRetries, setMaxProxyRetries] = useState(10); - const [logVisible, setLogVisible] = useState(true); const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const wsRef = useRef(null); - const logEndRef = useRef(null); - const user = getUser(); + const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); + const { can } = usePermissions(); const { token } = theme.useToken(); - const canBatch = hasPerm(user, 'login:batch'); + const canBatch = can('login:batch'); // 从账号中提取所有标签 const allTags = useMemo(() => { @@ -113,20 +111,15 @@ export default function LoginTasksPage() { try { const data = await loginApi.listTasks(batchId || undefined); setTasks(data); - } catch {} + } catch { + // 忽略轮询失败,下一次定时刷新会继续尝试。 + } }; useEffect(() => { Promise.all([loadAccounts(), loadTasks()]); }, []); - // 日志自动滚动到底部 - useEffect(() => { - if (logVisible && logEndRef.current) { - logEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [logs, logVisible]); - useEffect(() => { const timer = setInterval(loadTasks, 3000); return () => clearInterval(timer); @@ -139,31 +132,15 @@ export default function LoginTasksPage() { return; } setLoading(true); - setLogs([]); try { const result = await loginApi.createBatch(accountIds, 5, concurrency, maxProxyRetries); setBatchId(result.batch_id); message.success(`已创建登录任务,共 ${result.count} 个账号`); - // 连接 WebSocket - const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/login/ws/login/${result.batch_id}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - setWsConnected(true); - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.level === 'heartbeat') return; - if (msg.level === 'result') return; - setLogs((prev) => [...prev, msg]); - }; - ws.onclose = () => { - wsRef.current = null; - setWsConnected(false); - setBatchId(null); - }; - ws.onerror = () => { - setWsConnected(false); - }; + connectLogs(`/api/login/ws/login/${result.batch_id}`, { + onClose: () => setBatchId(null), + onResult: () => setBatchId(null), + }); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { @@ -432,50 +409,13 @@ export default function LoginTasksPage() { {/* 实时日志 - 底部可折叠 */} -
-
setLogVisible((v) => !v)} - > - 实时日志 - {logVisible ? : } - {logs.length > 0 && {logs.length} 条} - {wsConnected && 连接中} -
- {logVisible && ( -
- {logs.length === 0 ? ( - - ) : ( - logs.map((log, i) => ( -
- {log.message} -
- )) - )} -
-
- )} -
+
); } diff --git a/web/frontend/src/pages/ProxyPage.tsx b/web/frontend/src/pages/ProxyPage.tsx index a9804a6..c15d584 100644 --- a/web/frontend/src/pages/ProxyPage.tsx +++ b/web/frontend/src/pages/ProxyPage.tsx @@ -1,19 +1,17 @@ -import { useEffect, useState, useRef } from 'react'; -import { Form, Input, Switch, Button, Card, message, Row, Col, theme } from 'antd'; +import { useEffect, useState } from 'react'; +import { Form, Input, Switch, Button, Card, message, Row, Col } from 'antd'; import { proxyApi, type ProxyConfig } from '../api/modules'; +import RealtimeLogPanel from '../components/RealtimeLogPanel'; +import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; import { getErrorMessage } from '../utils/error'; -const WS_BASE = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`; - export default function ProxyPage() { - const { token } = theme.useToken(); const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [testing, setTesting] = useState(false); const [testingWl, setTestingWl] = useState(false); const [configLoaded, setConfigLoaded] = useState(false); - const [logs, setLogs] = useState<{ level: string; message: string }[]>([]); - const wsRef = useRef(null); + const { logs, connect: connectLogs, close: closeLogs } = useWebSocketLogs(); const loadConfig = async () => { try { @@ -37,30 +35,21 @@ export default function ProxyPage() { useEffect(() => { loadConfig(); return () => { - wsRef.current?.close(); + closeLogs(); }; }, []); - const appendLog = (level: string, msg: string) => { - setLogs((prev) => [...prev, { level, message: msg }]); - }; - const connectWs = (testId: string) => { - wsRef.current?.close(); - setLogs([]); - const ws = new WebSocket(`${WS_BASE}/api/proxy/ws/test/${testId}`); - wsRef.current = ws; - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.level === 'heartbeat') return; - if (msg.level === 'result') return; - appendLog(msg.level, msg.message); - }; - ws.onclose = () => { - wsRef.current = null; - setTesting(false); - setTestingWl(false); - }; + connectLogs(`/api/proxy/ws/test/${testId}`, { + onClose: () => { + setTesting(false); + setTestingWl(false); + }, + onResult: () => { + setTesting(false); + setTestingWl(false); + }, + }); }; const handleSave = async () => { @@ -78,7 +67,6 @@ export default function ProxyPage() { const handleTestProxy = async () => { setTesting(true); - setLogs([]); try { const result = await proxyApi.test(); if (result.test_id) connectWs(result.test_id); @@ -90,7 +78,6 @@ export default function ProxyPage() { const handleTestWhitelist = async () => { setTestingWl(true); - setLogs([]); try { const result = await proxyApi.testWhitelist(); if (result.test_id) connectWs(result.test_id); @@ -101,13 +88,6 @@ export default function ProxyPage() { }; - const logColors: Record = { - error: token.colorError, - success: token.colorSuccess, - warning: token.colorWarning, - info: token.colorText, - }; - return (
@@ -166,22 +146,15 @@ export default function ProxyPage() { - - {logs.length === 0 ? ( - 点击"测试代理"或"测试白名单"查看日志 - ) : ( - logs.map((log, i) => ( -
- {log.message} -
- )) - )} -
+ emptyText={'点击"测试代理"或"测试白名单"查看日志'} + height="100%" + bodyStyle={{ minHeight: 160 }} + style={{ flex: 1 }} + />
); } diff --git a/web/frontend/src/pages/UsersPage.tsx b/web/frontend/src/pages/UsersPage.tsx index 20fc40c..ab8371c 100644 --- a/web/frontend/src/pages/UsersPage.tsx +++ b/web/frontend/src/pages/UsersPage.tsx @@ -5,7 +5,7 @@ import { } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons'; import { userApi, type UserInfo } from '../api/modules'; -import { getUser, hasPerm } from '../store/auth'; +import { usePermissions } from '../hooks/usePermissions'; import { getErrorMessage } from '../utils/error'; const ROLE_OPTIONS = [ @@ -50,8 +50,9 @@ export default function UsersPage() { const [selectedPerms, setSelectedPerms] = useState([]); const [useCustom, setUseCustom] = useState(false); const [permLoading, setPermLoading] = useState(false); + const { can } = usePermissions(); - const canAssignPerm = hasPerm(getUser(), 'user:assign_permissions'); + const canAssignPerm = can('user:assign_permissions'); const loadUsers = async () => { setLoading(true);