File size: 8,979 Bytes
85270f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | from __future__ import annotations
from pathlib import Path
from threading import Event, Thread
import time
from fastapi import HTTPException, Request
from services.account_service import account_service
from services.auth_service import auth_service
from services.config import config
BASE_DIR = Path(__file__).resolve().parents[1]
WEB_DIST_DIR = BASE_DIR / "web_dist"
def extract_bearer_token(authorization: str | None) -> str:
scheme, _, value = str(authorization or "").partition(" ")
if scheme.lower() != "bearer" or not value.strip():
return ""
return value.strip()
def _legacy_admin_identity(token: str) -> dict[str, object] | None:
auth_key = str(config.auth_key or "").strip()
if auth_key and token == auth_key:
return {"id": "admin", "name": "管理员", "role": "admin", "account_pool_enabled": True}
return None
def require_identity(authorization: str | None, *, touch: bool = True) -> dict[str, object]:
token = extract_bearer_token(authorization)
identity = _legacy_admin_identity(token) or (
auth_service.authenticate(token)
if touch
else auth_service.authenticate(token, touch=False)
)
if identity is None:
raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
return identity
def require_auth_key(authorization: str | None) -> None:
require_identity(authorization)
def require_admin(authorization: str | None) -> dict[str, object]:
identity = require_identity(authorization)
if identity.get("role") != "admin":
raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
return identity
def resolve_image_base_url(request: Request) -> str:
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip()
forwarded_host = str(request.headers.get("x-forwarded-host") or "").split(",", 1)[0].strip()
proto = forwarded_proto or request.url.scheme
host = forwarded_host or request.headers.get("host", request.url.netloc)
return config.base_url or f"{proto}://{host}"
def raise_image_quota_error(exc: Exception) -> None:
message = str(exc)
if "no available image quota" in message.lower():
raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc
raise HTTPException(status_code=502, detail={"error": message}) from exc
def sanitize_cpa_pool(pool: dict | None) -> dict | None:
if not isinstance(pool, dict):
return None
return {key: value for key, value in pool.items() if key != "secret_key"}
def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
def sanitize_sub2api_server(server: dict | None) -> dict | None:
if not isinstance(server, dict):
return None
sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}}
sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip())
return sanitized
def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]:
return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None]
def start_limited_account_watcher(stop_event: Event) -> Thread:
def worker() -> None:
started_at = time.monotonic()
last_refresh_at = started_at
last_free_cleanup_at = started_at
last_redeem_code_cleanup_at = 0.0
while not stop_event.is_set():
refresh_interval_seconds = max(60, config.refresh_account_interval_minute * 60)
free_cleanup_settings = config.get_free_account_cleanup_settings()
free_cleanup_enabled = bool(free_cleanup_settings.get("enabled"))
free_cleanup_interval_seconds = max(60, int(free_cleanup_settings.get("interval_minutes") or 10) * 60)
redeem_code_cleanup_settings = config.get_redeem_code_cleanup_settings()
redeem_code_cleanup_enabled = bool(redeem_code_cleanup_settings.get("enabled"))
redeem_code_cleanup_interval_seconds = max(
60,
int(redeem_code_cleanup_settings.get("interval_minutes") or 1440) * 60,
)
now = time.monotonic()
try:
if now - last_refresh_at >= refresh_interval_seconds:
session_result = account_service.refresh_expiring_chatgpt_sessions(
"account_watcher_session_refresh"
)
if int(session_result.get("checked") or 0):
print(
"[account-session-refresh-watcher] "
f"checked {session_result.get('checked', 0)}, "
f"refreshed {session_result.get('refreshed', 0)}, "
f"rotated {session_result.get('rotated', 0)}, "
f"errors {len(session_result.get('errors') or [])}"
)
limited_tokens = account_service.list_limited_tokens()
if limited_tokens:
print(f"[account-limited-watcher] checking {len(limited_tokens)} limited accounts")
account_service.refresh_accounts(limited_tokens)
last_refresh_at = now
if free_cleanup_enabled and now - last_free_cleanup_at >= free_cleanup_interval_seconds:
result = account_service.refresh_normal_free_accounts("account_watcher_free_cleanup")
checked = int(result.get("checked") or 0)
if checked:
print(
"[account-free-cleanup-watcher] "
f"checked {checked} normal free accounts, "
f"refreshed {result.get('refreshed', 0)}, "
f"errors {len(result.get('errors') or [])}"
)
last_free_cleanup_at = now
if redeem_code_cleanup_enabled and now - last_redeem_code_cleanup_at >= redeem_code_cleanup_interval_seconds:
from services.shop_service import shop_service
result = shop_service.cleanup_codes(
delete_expired=bool(redeem_code_cleanup_settings.get("delete_expired")),
delete_disabled=bool(redeem_code_cleanup_settings.get("delete_disabled")),
delete_redeemed=bool(redeem_code_cleanup_settings.get("archive_redeemed")),
redeemed_older_than_days=int(redeem_code_cleanup_settings.get("redeemed_older_than_days") or 90),
limit=int(redeem_code_cleanup_settings.get("limit") or 1000),
dry_run=False,
)
summary = result.get("summary") or {}
deleted = int(summary.get("deleted") or 0)
if deleted:
print(
"[redeem-code-cleanup-watcher] "
f"deleted {deleted} codes, archived {summary.get('archived', 0)}, "
f"archive {summary.get('archive_path') or '-'}"
)
last_redeem_code_cleanup_at = now
except Exception as exc:
print(f"[account-limited-watcher] fail {exc}")
last_refresh_at = now
if free_cleanup_enabled:
last_free_cleanup_at = now
if redeem_code_cleanup_enabled:
last_redeem_code_cleanup_at = now
wait_candidates = [last_refresh_at + refresh_interval_seconds]
if free_cleanup_enabled:
wait_candidates.append(last_free_cleanup_at + free_cleanup_interval_seconds)
if redeem_code_cleanup_enabled:
wait_candidates.append(last_redeem_code_cleanup_at + redeem_code_cleanup_interval_seconds)
wait_seconds = max(1.0, min(wait_candidates) - time.monotonic())
stop_event.wait(wait_seconds)
thread = Thread(target=worker, name="limited-account-watcher", daemon=True)
thread.start()
return thread
def resolve_web_asset(requested_path: str) -> Path | None:
if not WEB_DIST_DIR.exists():
return None
clean_path = requested_path.strip("/")
base_dir = WEB_DIST_DIR.resolve()
candidates = [base_dir / "index.html"] if not clean_path else [
base_dir / Path(clean_path),
base_dir / clean_path / "index.html",
base_dir / f"{clean_path}.html",
]
for candidate in candidates:
try:
candidate.resolve().relative_to(base_dir)
except ValueError:
continue
if candidate.is_file():
return candidate
return None
|