from __future__ import annotations import json import time from datetime import datetime, timedelta from typing import Any from urllib.parse import urlparse, urlunparse from core.config import ( CDN_ALLOWED_DOMAINS, CDN_PURGE_TASK_LIMIT, DEPLOY_REGION, TENCENT_SECRET_ID, TENCENT_SECRET_KEY, utc_now, ) URL_PURGE_BATCH_LIMIT = 1000 PATH_PURGE_BATCH_LIMIT = 500 def cdn_configuration_error() -> str: # CDN 刷新和现有腾讯云模块共用同一组凭据;少任意一项都直接视为未配置。 missing: list[str] = [] if not TENCENT_SECRET_ID: missing.append("TENCENT_SECRET_ID") if not TENCENT_SECRET_KEY: missing.append("TENCENT_SECRET_KEY") if not DEPLOY_REGION: missing.append("DEPLOY_REGION") if missing: return f"cdn refresh requires .env: {', '.join(missing)}" return "" def normalized_cdn_allowed_domains() -> set[str]: # 域名白名单统一转成小写集合,前后端展示和后端校验保持一套口径。 return {str(item or "").strip().lower() for item in CDN_ALLOWED_DOMAINS if str(item or "").strip()} def build_cdn_client() -> tuple[Any | None, str]: # 先兜底检查配置,避免没有凭据时还继续初始化 SDK。 error = cdn_configuration_error() if error: return None, error try: from tencentcloud.cdn.v20180606 import cdn_client from tencentcloud.common import credential from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile except Exception as exc: # noqa: BLE001 return None, f"tencentcloud sdk unavailable: {exc}" cred = credential.Credential(TENCENT_SECRET_ID, TENCENT_SECRET_KEY) client = cdn_client.CdnClient( cred, DEPLOY_REGION, ClientProfile(httpProfile=HttpProfile(endpoint="cdn.tencentcloudapi.com")), ) return client, "" def invoke_cdn(action: Any, label: str, retries: int = 3, delay_seconds: int = 2) -> Any: # CDN SDK 异常和 CLB/TAT 一样集中做轻量重试,减少瞬时网络抖动带来的假失败。 from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException last_error: Exception | None = None for attempt in range(1, retries + 1): try: return action() except TencentCloudSDKException as exc: last_error = exc if attempt == retries: break time.sleep(delay_seconds * attempt) if last_error is not None: raise last_error raise RuntimeError(f"{label} failed without error") def call_cdn(method_name: str, payload: dict[str, Any]) -> dict[str, Any]: # 所有 CDN 调用统一走 JSON 请求体,后续补字段时不需要反复修改 SDK 模型赋值逻辑。 client, error = build_cdn_client() if client is None: raise RuntimeError(error) from tencentcloud.cdn.v20180606 import models as cdn_models request_class = getattr(cdn_models, f"{method_name}Request") request = request_class() request.from_json_string(json.dumps(payload)) response = invoke_cdn(lambda: getattr(client, method_name)(request), method_name) payload = json.loads(response.to_json_string()) if isinstance(payload, dict) and isinstance(payload.get("Response"), dict): return dict(payload.get("Response") or {}) return dict(payload or {}) def normalize_cdn_purge_type(value: Any) -> str: # 工具页只开放 URL 和目录两种刷新类型。 purge_type = str(value or "url").strip().lower() if purge_type not in {"url", "path"}: raise ValueError("purgeType must be url or path") return purge_type def normalize_cdn_area(value: Any, *, allow_global: bool = False) -> str: # 刷新提交只接受境内/境外;记录筛选额外兼容 global。 area = str(value or "").strip().lower() if not area: return "" allowed = {"mainland", "overseas"} if allow_global: allowed.add("global") if area not in allowed: raise ValueError(f"area must be one of: {', '.join(sorted(allowed))}") return area def normalize_cdn_flush_type(purge_type: str, value: Any) -> str: # URL 刷新没有 flushType;目录刷新默认走更保守的 flush。 flush_type = str(value or "flush").strip().lower() if purge_type == "url": return "" if flush_type not in {"flush", "delete"}: raise ValueError("flushType must be flush or delete") return flush_type def normalize_cdn_items(raw_items: Any) -> list[str]: # 前端可直接传数组;兜底兼容换行文本,方便后续脚本接口复用。 if isinstance(raw_items, list): candidates = raw_items elif isinstance(raw_items, str): candidates = raw_items.splitlines() else: raise ValueError("items must be a list or string") items: list[str] = [] seen: set[str] = set() for item in candidates: text = str(item or "").strip() if not text or text in seen: continue seen.add(text) items.append(text) if not items: raise ValueError("至少填写一条 URL 或目录") return items def normalize_cdn_item(item: str, *, purge_type: str, allowed_domains: set[str]) -> str: # 每条刷新目标都强制要求完整 URL,避免误把裸路径提交到错误域名。 parsed = urlparse(item) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError(f"无效 CDN 地址: {item}") hostname = str(parsed.hostname or "").strip().lower() if allowed_domains and hostname not in allowed_domains: raise ValueError(f"域名未加入 CDN_ALLOWED_DOMAINS: {hostname}") if purge_type == "path": normalized_path = parsed.path or "/" if not normalized_path.endswith("/"): raise ValueError(f"目录刷新必须以 / 结尾: {item}") parsed = parsed._replace(path=normalized_path, query="", fragment="") else: parsed = parsed._replace(fragment="") return urlunparse(parsed) def normalize_cdn_purge_items(raw_items: Any, *, purge_type: str) -> list[str]: # 去重、域名校验和目录校验统一收口,提交接口和测试都复用这套规则。 items = normalize_cdn_items(raw_items) allowed_domains = normalized_cdn_allowed_domains() max_batch = URL_PURGE_BATCH_LIMIT if purge_type == "url" else PATH_PURGE_BATCH_LIMIT if len(items) > max_batch: raise ValueError(f"{'URL' if purge_type == 'url' else '目录'} 单次最多提交 {max_batch} 条") normalized: list[str] = [] seen: set[str] = set() for item in items: value = normalize_cdn_item(item, purge_type=purge_type, allowed_domains=allowed_domains) if value in seen: continue seen.add(value) normalized.append(value) return normalized def normalize_quota_rows(items: list[dict[str, Any]]) -> list[dict[str, Any]]: # 配额结构只保留页面展示会用到的字段,前端不需要感知腾讯云原始大小写命名。 rows: list[dict[str, Any]] = [] for item in items: rows.append( { "area": str(item.get("Area") or "").strip().lower(), "batch": int(item.get("Batch") or 0), "total": int(item.get("Total") or 0), "available": int(item.get("Available") or 0), } ) return rows def normalize_purge_task_rows(items: list[dict[str, Any]]) -> list[dict[str, Any]]: # 刷新记录统一转成前端直接可表格渲染的结构,并按提交时间倒序排列。 rows: list[dict[str, Any]] = [] for item in items: rows.append( { "taskId": str(item.get("TaskId") or "").strip(), "url": str(item.get("Url") or "").strip(), "status": str(item.get("Status") or "").strip().lower(), "purgeType": str(item.get("PurgeType") or "").strip().lower(), "flushType": str(item.get("FlushType") or "").strip().lower(), "createTime": str(item.get("CreateTime") or "").strip(), } ) return sorted(rows, key=lambda item: item.get("createTime") or "", reverse=True) def recent_task_query_window() -> tuple[str, str]: # CDN 记录查询要求传 TaskId 或时间范围;工具页默认拉最近 7 天窗口。 end_at = datetime.now().replace(microsecond=0) start_at = end_at - timedelta(days=7) return start_at.strftime("%Y-%m-%d %H:%M:%S"), end_at.strftime("%Y-%m-%d %H:%M:%S") def get_cdn_overview_payload() -> dict[str, Any]: # 通用工具页一次拿到配置状态、配额和最近刷新记录,减少前端额外往返。 payload: dict[str, Any] = { "ok": False, "configured": False, "updatedAt": utc_now(), "error": "", "warnings": [], "settings": { "allowedDomains": sorted(normalized_cdn_allowed_domains()), "taskLimit": max(int(CDN_PURGE_TASK_LIMIT or 20), 1), }, "quota": { "url": [], "path": [], }, "tasks": [], "taskTotal": 0, } error = cdn_configuration_error() if error: payload["error"] = error return payload payload["configured"] = True try: quota_payload = call_cdn("DescribePurgeQuota", {}) payload["quota"]["url"] = normalize_quota_rows(list(quota_payload.get("UrlPurge") or [])) payload["quota"]["path"] = normalize_quota_rows(list(quota_payload.get("PathPurge") or [])) except Exception as exc: # noqa: BLE001 payload["warnings"].append(f"quota: {exc}") try: start_time, end_time = recent_task_query_window() tasks_payload = call_cdn( "DescribePurgeTasks", { "StartTime": start_time, "EndTime": end_time, "Offset": 0, "Limit": max(int(CDN_PURGE_TASK_LIMIT or 20), 1), }, ) payload["tasks"] = normalize_purge_task_rows(list(tasks_payload.get("PurgeLogs") or [])) payload["taskTotal"] = int(tasks_payload.get("TotalCount") or len(payload["tasks"])) except Exception as exc: # noqa: BLE001 payload["warnings"].append(f"tasks: {exc}") payload["ok"] = True return payload def submit_cdn_purge_payload(payload: dict[str, Any]) -> dict[str, Any]: # 刷新任务提交前先做本地校验,把明显错误拦在页面内。 error = cdn_configuration_error() if error: raise RuntimeError(error) purge_type = normalize_cdn_purge_type(payload.get("purgeType")) items = normalize_cdn_purge_items(payload.get("items"), purge_type=purge_type) area = normalize_cdn_area(payload.get("area")) flush_type = normalize_cdn_flush_type(purge_type, payload.get("flushType")) url_encode = bool(payload.get("urlEncode")) request_payload: dict[str, Any] = {"UrlEncode": url_encode} if area: request_payload["Area"] = area if purge_type == "url": request_payload["Urls"] = items response = call_cdn("PurgeUrlsCache", request_payload) else: request_payload["Paths"] = items request_payload["FlushType"] = flush_type or "flush" response = call_cdn("PurgePathCache", request_payload) return { "ok": True, "taskId": str(response.get("TaskId") or "").strip(), "requestId": str(response.get("RequestId") or "").strip(), "purgeType": purge_type, "area": area, "flushType": flush_type, "urlEncode": url_encode, "itemCount": len(items), "items": items, "createdAt": utc_now(), }