Files
Random-image-api/backend/app/core/proxy_routing.py
T
2026-02-22 18:58:55 +08:00

540 lines
17 KiB
Python

from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
from urllib.parse import quote
from sqlalchemy.ext.asyncio import AsyncEngine
from app.core.config import Settings
from app.core.crypto import FieldEncryptor
from app.core.errors import ApiError, ErrorCode
from app.core.runtime_settings import RuntimeConfig
from app.core.time import iso_utc_ms
from app.db.session import with_sqlite_busy_retry
_PIXIV_HOST_SUFFIXES = (
"pixiv.net",
"pximg.net",
"secure.pixiv.net",
)
def _normalize_host(host: str) -> str:
return (host or "").strip().lower().strip(".")
def host_from_url(url: str) -> str | None:
url = (url or "").strip()
if not url:
return None
try:
parsed = urlparse(url)
except Exception:
return None
host = _normalize_host(parsed.hostname or "")
return host or None
def _suffix_match(*, host: str, suffix: str) -> bool:
host = _normalize_host(host)
suffix = _normalize_host(suffix)
if not host or not suffix:
return False
return host == suffix or host.endswith("." + suffix)
def should_use_proxy_for_host(runtime: RuntimeConfig, *, host: str) -> bool:
if not bool(runtime.proxy_enabled):
return False
host_n = _normalize_host(host)
if not host_n:
return False
mode = (runtime.proxy_route_mode or "").strip().lower()
if mode in {"off"}:
return False
if mode == "all":
return True
if mode == "allowlist":
return any(_suffix_match(host=host_n, suffix=d) for d in (runtime.proxy_allowlist_domains or []))
if mode == "pixiv_only":
return any(_suffix_match(host=host_n, suffix=s) for s in _PIXIV_HOST_SUFFIXES)
return False
def resolve_pool_id_for_host(runtime: RuntimeConfig, *, host: str) -> int | None:
host_n = _normalize_host(host)
if not host_n:
return None
best_len = -1
best: int | None = None
for suffix, pool_id in (runtime.proxy_route_pools or {}).items():
suf = _normalize_host(str(suffix))
if not suf:
continue
if not _suffix_match(host=host_n, suffix=suf):
continue
if len(suf) > best_len:
best_len = len(suf)
best = int(pool_id)
if best is not None and int(best) > 0:
return int(best)
if runtime.proxy_default_pool_id is not None and int(runtime.proxy_default_pool_id) > 0:
return int(runtime.proxy_default_pool_id)
return None
def _weighted_choice(items: list[tuple[int, int]]) -> int | None:
if not items:
return None
total = sum(max(0, int(w)) for _pid, w in items)
if total <= 0:
items2 = [pid for pid, _w in items]
return int(random.choice(items2)) if items2 else None
r = random.random() * total
for pid, w in items:
w_i = max(0, int(w))
if w_i <= 0:
continue
if r < w_i:
return int(pid)
r -= w_i
return int(items[-1][0])
@dataclass(frozen=True, slots=True)
class ProxyUri:
uri: str
endpoint_id: int
pool_id: int
async def _first_enabled_pool_id(engine: AsyncEngine) -> int | None:
sql = "SELECT id FROM proxy_pools WHERE enabled=1 ORDER BY id ASC LIMIT 1;"
async def _op() -> int | None:
async with engine.connect() as conn:
result = await conn.exec_driver_sql(sql)
value = result.scalar_one_or_none()
return int(value) if value is not None else None
return await with_sqlite_busy_retry(_op)
async def _list_enabled_pool_ids(engine: AsyncEngine) -> list[int]:
sql = "SELECT id FROM proxy_pools WHERE enabled=1 ORDER BY id ASC;"
async def _op() -> list[int]:
async with engine.connect() as conn:
rows = (await conn.exec_driver_sql(sql)).fetchall()
out: list[int] = []
seen: set[int] = set()
for (pid,) in rows:
try:
pool_id = int(pid)
except Exception:
continue
if pool_id <= 0 or pool_id in seen:
continue
seen.add(pool_id)
out.append(pool_id)
return out
return await with_sqlite_busy_retry(_op)
async def _pool_health_stats(engine: AsyncEngine, *, pool_id: int, now_iso: str) -> dict[str, Any]:
sql = """
SELECT
COUNT(*) AS endpoints_total,
COALESCE(SUM(CASE
WHEN pe.blacklisted_until IS NULL OR pe.blacklisted_until <= :now THEN 1
ELSE 0
END), 0) AS endpoints_eligible,
MIN(CASE
WHEN pe.blacklisted_until > :now THEN pe.blacklisted_until
ELSE NULL
END) AS next_available_at
FROM proxy_pools pp
JOIN proxy_pool_endpoints ppe
ON ppe.pool_id = pp.id AND ppe.enabled = 1
JOIN proxy_endpoints pe
ON pe.id = ppe.endpoint_id AND pe.enabled = 1
WHERE pp.id = :pool_id AND pp.enabled = 1;
""".strip()
async def _op() -> dict[str, Any]:
async with engine.connect() as conn:
row = (await conn.exec_driver_sql(sql, {"pool_id": int(pool_id), "now": str(now_iso)})).first()
if row is None:
return {"endpoints_total": 0, "endpoints_eligible": 0, "next_available_at": None}
total = int(row[0] or 0)
eligible = int(row[1] or 0)
next_available_at = str(row[2]).strip() if row[2] is not None else None
return {
"endpoints_total": max(0, total),
"endpoints_eligible": max(0, eligible),
"next_available_at": next_available_at or None,
}
return await with_sqlite_busy_retry(_op)
async def _pick_endpoint_in_pool(engine: AsyncEngine, *, pool_id: int, now_iso: str) -> tuple[int, str, str, int, str, str] | None:
sql = """
SELECT pe.id, pe.scheme, pe.host, pe.port, pe.username, pe.password_enc, ppe.weight, pe.last_ok_at, pe.last_fail_at
FROM proxy_pools pp
JOIN proxy_pool_endpoints ppe
ON ppe.pool_id = pp.id AND ppe.enabled = 1
JOIN proxy_endpoints pe
ON pe.id = ppe.endpoint_id AND pe.enabled = 1
WHERE pp.id = :pool_id AND pp.enabled = 1
AND (pe.blacklisted_until IS NULL OR pe.blacklisted_until <= :now)
ORDER BY pe.id ASC;
""".strip()
async def _op() -> tuple[int, str, str, int, str, str] | None:
async with engine.connect() as conn:
result = await conn.exec_driver_sql(sql, {"pool_id": int(pool_id), "now": now_iso})
rows = result.fetchall()
ok_weighted: list[tuple[int, int]] = []
unknown_weighted: list[tuple[int, int]] = []
fail_weighted: list[tuple[int, int]] = []
for r in rows:
endpoint_id = int(r[0])
weight = int(r[6] or 0)
last_ok_at = str(r[7]).strip() if r[7] is not None else ""
last_fail_at = str(r[8]).strip() if r[8] is not None else ""
if last_ok_at and (not last_fail_at or last_ok_at >= last_fail_at):
ok_weighted.append((endpoint_id, weight))
elif not last_ok_at and not last_fail_at:
unknown_weighted.append((endpoint_id, weight))
else:
fail_weighted.append((endpoint_id, weight))
weighted = ok_weighted or unknown_weighted or fail_weighted
chosen_id = _weighted_choice(weighted)
if chosen_id is None:
return None
for r in rows:
if int(r[0]) == int(chosen_id):
return (
int(r[0]),
str(r[1]),
str(r[2]),
int(r[3]),
str(r[4] or ""),
str(r[5] or ""),
)
return None
return await with_sqlite_busy_retry(_op)
async def _load_token_binding(
engine: AsyncEngine,
*,
token_id: int,
pool_id: int,
) -> tuple[int, int | None, str | None] | None:
sql = """
SELECT primary_proxy_id, override_proxy_id, override_expires_at
FROM token_proxy_bindings
WHERE token_id=:token_id AND pool_id=:pool_id
LIMIT 1;
""".strip()
async def _op() -> tuple[int, int | None, str | None] | None:
async with engine.connect() as conn:
result = await conn.exec_driver_sql(sql, {"token_id": int(token_id), "pool_id": int(pool_id)})
row = result.first()
if row is None:
return None
primary_id = int(row[0])
override_id = int(row[1]) if row[1] is not None else None
override_expires_at = str(row[2]) if row[2] is not None else None
return primary_id, override_id, override_expires_at
return await with_sqlite_busy_retry(_op)
async def _load_endpoint_in_pool(
engine: AsyncEngine,
*,
pool_id: int,
endpoint_id: int,
now_iso: str,
) -> tuple[int, str, str, int, str, str] | None:
sql = """
SELECT pe.id, pe.scheme, pe.host, pe.port, pe.username, pe.password_enc
FROM proxy_pools pp
JOIN proxy_pool_endpoints ppe
ON ppe.pool_id = pp.id AND ppe.enabled = 1
JOIN proxy_endpoints pe
ON pe.id = ppe.endpoint_id AND pe.enabled = 1
WHERE pp.id = :pool_id AND pp.enabled = 1
AND pe.id = :endpoint_id
AND (pe.blacklisted_until IS NULL OR pe.blacklisted_until <= :now)
LIMIT 1;
""".strip()
async def _op() -> tuple[int, str, str, int, str, str] | None:
async with engine.connect() as conn:
result = await conn.exec_driver_sql(
sql,
{"pool_id": int(pool_id), "endpoint_id": int(endpoint_id), "now": now_iso},
)
row = result.first()
if row is None:
return None
return (
int(row[0]),
str(row[1]),
str(row[2]),
int(row[3]),
str(row[4] or ""),
str(row[5] or ""),
)
return await with_sqlite_busy_retry(_op)
def _build_proxy_uri(
encryptor: FieldEncryptor | None,
*,
scheme: str,
host: str,
port: int,
username: str,
password_enc: str,
) -> str:
scheme = (scheme or "").strip().lower()
host = (host or "").strip()
username = (username or "").strip()
password_enc = (password_enc or "").strip()
if not scheme or not host or int(port) <= 0:
raise ValueError("invalid proxy endpoint")
password = ""
if password_enc:
if encryptor is None:
raise ValueError("FIELD_ENCRYPTION_KEY is required to decrypt proxy password")
password = encryptor.decrypt_text(password_enc) if password_enc else ""
host_part = host
if ":" in host_part and not host_part.startswith("["):
host_part = f"[{host_part}]"
auth = ""
if username:
user_q = quote(username, safe="")
pass_q = quote(password or "", safe="")
auth = f"{user_q}:{pass_q}@"
return f"{scheme}://{auth}{host_part}:{int(port)}"
def _proxy_uri_from_endpoint_row(
settings: Settings,
*,
endpoint_id: int,
pool_id: int,
scheme: str,
host: str,
port: int,
username: str,
password_enc: str,
) -> ProxyUri:
encryptor = None
if str(password_enc or "").strip():
try:
encryptor = FieldEncryptor.from_key(settings.field_encryption_key)
except Exception as exc:
raise ApiError(code=ErrorCode.INTERNAL_ERROR, message="Encryption not configured", status_code=500) from exc
try:
uri = _build_proxy_uri(
encryptor,
scheme=scheme,
host=host,
port=int(port),
username=username,
password_enc=password_enc,
)
except ApiError:
raise
except Exception as exc:
raise ApiError(code=ErrorCode.INTERNAL_ERROR, message="Invalid proxy endpoint", status_code=500) from exc
return ProxyUri(uri=uri, endpoint_id=int(endpoint_id), pool_id=int(pool_id))
async def select_proxy_uri_for_url(
engine: AsyncEngine,
settings: Settings,
runtime: RuntimeConfig,
*,
url: str,
now_iso: str | None = None,
token_id: int | None = None,
) -> ProxyUri | None:
host = host_from_url(url)
if host is None:
return None
if not should_use_proxy_for_host(runtime, host=host):
return None
now_iso = now_iso or iso_utc_ms()
preferred_pool_id = resolve_pool_id_for_host(runtime, host=host)
enabled_pools = await _list_enabled_pool_ids(engine)
enabled_pool_set = {int(pid) for pid in enabled_pools}
pool_candidates: list[int] = []
if preferred_pool_id is not None and int(preferred_pool_id) > 0:
preferred_pool_id_i = int(preferred_pool_id)
if preferred_pool_id_i in enabled_pool_set:
pool_candidates.append(preferred_pool_id_i)
for pid in enabled_pools:
if pid not in pool_candidates:
pool_candidates.append(int(pid))
if not pool_candidates:
if bool(runtime.proxy_fail_closed):
raise ApiError(
code=ErrorCode.PROXY_REQUIRED,
message="需要代理,但未配置代理池",
status_code=502,
details={
"reason": "no_proxy_pool_configured",
"host": host,
"url": url,
},
)
return None
async def _select_in_pool(pool_id: int) -> ProxyUri | None:
if int(pool_id) <= 0:
return None
if token_id is not None and int(token_id) > 0:
binding = await _load_token_binding(engine, token_id=int(token_id), pool_id=int(pool_id))
if binding is not None:
primary_proxy_id, override_proxy_id, override_expires_at = binding
override_active = bool(
override_proxy_id is not None
and override_expires_at
and str(override_expires_at) > str(now_iso)
)
candidates: list[int] = []
if override_active and override_proxy_id is not None:
candidates.append(int(override_proxy_id))
candidates.append(int(primary_proxy_id))
for endpoint_id in candidates:
picked_by_binding = await _load_endpoint_in_pool(
engine,
pool_id=int(pool_id),
endpoint_id=int(endpoint_id),
now_iso=str(now_iso),
)
if picked_by_binding is None:
continue
eid, scheme, host_v, port, username, password_enc = picked_by_binding
return _proxy_uri_from_endpoint_row(
settings,
endpoint_id=int(eid),
pool_id=int(pool_id),
scheme=scheme,
host=host_v,
port=int(port),
username=username,
password_enc=password_enc,
)
picked = await _pick_endpoint_in_pool(engine, pool_id=int(pool_id), now_iso=now_iso)
if picked is None:
return None
endpoint_id, scheme, host_v, port, username, password_enc = picked
return _proxy_uri_from_endpoint_row(
settings,
endpoint_id=int(endpoint_id),
pool_id=int(pool_id),
scheme=scheme,
host=host_v,
port=int(port),
username=username,
password_enc=password_enc,
)
for candidate_pool_id in pool_candidates:
picked = await _select_in_pool(int(candidate_pool_id))
if picked is not None:
return picked
if bool(runtime.proxy_fail_closed):
pool_stats: list[dict[str, Any]] = []
next_available_at: str | None = None
total_any = 0
eligible_any = 0
any_next = False
for pid in pool_candidates:
stats = await _pool_health_stats(engine, pool_id=int(pid), now_iso=str(now_iso))
total = int(stats.get("endpoints_total") or 0)
eligible = int(stats.get("endpoints_eligible") or 0)
total_any += max(0, total)
eligible_any += max(0, eligible)
nxt = stats.get("next_available_at")
if isinstance(nxt, str) and nxt.strip():
any_next = True
if next_available_at is None or str(nxt) < str(next_available_at):
next_available_at = str(nxt)
pool_stats.append({"pool_id": int(pid), **stats})
reason = "no_healthy_proxy_available"
if total_any <= 0:
reason = "pool_has_no_endpoints"
elif eligible_any <= 0 and any_next and next_available_at:
reason = "all_endpoints_blacklisted"
primary_pool_id = (
int(preferred_pool_id)
if preferred_pool_id is not None and int(preferred_pool_id) > 0
else int(pool_candidates[0])
)
raise ApiError(
code=ErrorCode.PROXY_REQUIRED,
message="需要代理,但当前没有可用代理节点",
status_code=502,
details={
"reason": reason,
"host": host,
"url": url,
"pool_id": primary_pool_id,
"attempted_pool_ids": [int(pid) for pid in pool_candidates],
"pools": pool_stats,
"endpoints_total": int(total_any),
"endpoints_eligible": int(eligible_any),
"next_available_at": next_available_at,
},
)
return None