Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| from urllib.parse import quote | |
| from fastapi import APIRouter, Header, HTTPException, Query, Request | |
| from fastapi.concurrency import run_in_threadpool | |
| from fastapi.responses import HTMLResponse, Response, StreamingResponse | |
| from pydantic import BaseModel, ConfigDict | |
| from api.support import require_admin, require_identity, resolve_image_base_url | |
| from services.backup_service import BackupError, backup_service | |
| from services.config import config | |
| from services.image_service import ( | |
| compress_images, | |
| delete_images, | |
| delete_to_target, | |
| download_images_zip, | |
| get_image_download_response, | |
| get_image_response, | |
| get_thumbnail_response, | |
| list_images, | |
| storage_stats, | |
| ) | |
| from services.image_storage_service import ImageStorageError, image_storage_service | |
| from services.image_tags_service import delete_tag, get_all_tags, set_tags | |
| from services.log_service import log_service | |
| from services.proxy_service import test_proxy | |
| class SettingsUpdateRequest(BaseModel): | |
| model_config = ConfigDict(extra="allow") | |
| class ProxyTestRequest(BaseModel): | |
| url: str = "" | |
| class ImageDeleteRequest(BaseModel): | |
| paths: list[str] = [] | |
| start_date: str = "" | |
| end_date: str = "" | |
| all_matching: bool = False | |
| class ImageDownloadRequest(BaseModel): | |
| paths: list[str] | |
| class ImageTagsRequest(BaseModel): | |
| path: str | |
| tags: list[str] | |
| class LogDeleteRequest(BaseModel): | |
| ids: list[str] = [] | |
| class BackupDeleteRequest(BaseModel): | |
| key: str = "" | |
| def create_router(app_version: str) -> APIRouter: | |
| router = APIRouter() | |
| async def login(authorization: str | None = Header(default=None)): | |
| identity = require_identity(authorization) | |
| return { | |
| "ok": True, | |
| "version": app_version, | |
| "role": identity.get("role"), | |
| "subject_id": identity.get("id"), | |
| "name": identity.get("name"), | |
| } | |
| async def get_version(): | |
| return {"version": app_version} | |
| async def get_settings(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"config": config.get()} | |
| async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| return {"config": config.update(body.model_dump(mode="python"))} | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip()) | |
| async def get_image(image_path: str): | |
| return get_image_response(image_path) | |
| async def get_image_thumbnail(image_path: str): | |
| return get_thumbnail_response(image_path) | |
| async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching) | |
| async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| buf = download_images_zip(body.paths) | |
| return StreamingResponse( | |
| buf, | |
| media_type="application/zip", | |
| headers={"Content-Disposition": 'attachment; filename="images.zip"'}, | |
| ) | |
| async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return get_image_download_response(image_path) | |
| async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())} | |
| async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return log_service.delete(body.ids) | |
| async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| candidate = (body.url or "").strip() or config.get_proxy_settings() | |
| if not candidate: | |
| raise HTTPException(status_code=400, detail={"error": "proxy url is required"}) | |
| return {"result": await run_in_threadpool(test_proxy, candidate)} | |
| async def get_storage_info(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| storage = config.get_storage_backend() | |
| return { | |
| "backend": storage.get_backend_info(), | |
| "health": storage.health_check(), | |
| } | |
| async def test_backup_connection(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| return {"result": await run_in_threadpool(backup_service.test_connection)} | |
| except BackupError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def test_image_storage_endpoint(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"result": await run_in_threadpool(image_storage_service.test_webdav)} | |
| async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| return {"result": await run_in_threadpool(image_storage_service.sync_all)} | |
| except ImageStorageError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def get_backups(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| return { | |
| "items": await run_in_threadpool(backup_service.list_backups), | |
| "state": backup_service.get_status(), | |
| "settings": backup_service.get_settings(), | |
| } | |
| except BackupError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def run_backup_endpoint(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| return {"result": await run_in_threadpool(backup_service.run_backup)} | |
| except BackupError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| await run_in_threadpool(backup_service.delete_backup, body.key) | |
| return {"ok": True} | |
| except BackupError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)} | |
| except BackupError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| try: | |
| item = await run_in_threadpool(backup_service.download_backup, key) | |
| except BackupError as exc: | |
| raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc | |
| filename = str(item.get("name") or "backup.bin") | |
| quoted = quote(filename) | |
| headers = { | |
| "Content-Disposition": f"attachment; filename*=UTF-8''{quoted}", | |
| "Content-Length": str(int(item.get("size") or 0)), | |
| } | |
| return Response( | |
| content=bytes(item.get("payload") or b""), | |
| media_type=str(item.get("content_type") or "application/octet-stream"), | |
| headers=headers, | |
| ) | |
| async def list_image_tags(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return {"tags": get_all_tags()} | |
| async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| rel = body.path.strip().lstrip("/") | |
| if not rel: | |
| raise HTTPException(status_code=400, detail={"error": "path is required"}) | |
| tags = set_tags(rel, body.tags) | |
| return {"ok": True, "tags": tags} | |
| async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| count = delete_tag(tag) | |
| return {"ok": True, "removed_from": count} | |
| async def get_image_storage(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return storage_stats() | |
| async def compress_all_images(authorization: str | None = Header(default=None)): | |
| require_admin(authorization) | |
| return await run_in_threadpool(compress_images) | |
| async def cleanup_to_target( | |
| target_free_mb: int = 500, | |
| dry_run: bool = False, | |
| authorization: str | None = Header(default=None), | |
| ): | |
| require_admin(authorization) | |
| return await run_in_threadpool(delete_to_target, target_free_mb, dry_run) | |
| async def health_dashboard(format: str = Query(default="html")): | |
| from services.account_service import account_service as acct_svc | |
| stats = acct_svc.get_stats() | |
| storage = config.get_storage_backend() | |
| storage_health = storage.health_check() | |
| healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0 | |
| stats_json = { | |
| "status": "ok" if healthy else "degraded", | |
| "healthy": healthy, | |
| "version": app_version, | |
| "storage": {"backend": storage.get_backend_info(), "health": storage_health}, | |
| "accounts": stats, | |
| } | |
| if format == "json": | |
| return stats_json | |
| return HTMLResponse(f"""<!DOCTYPE html> | |
| <html lang="zh"> | |
| <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>号池健康监控 - chatgpt2api</title> | |
| <style> | |
| *{{margin:0;padding:0;box-sizing:border-box}} | |
| body{{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}} | |
| .header{{background:#1a1d27;border-bottom:1px solid #2a2d3a;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}} | |
| .header h1{{font-size:20px}} | |
| .status-dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}} | |
| .status-ok{{background:#22c55e;box-shadow:0 0 8px #22c55e88}} | |
| .status-degraded{{background:#f59e0b;box-shadow:0 0 8px #f59e0b88}} | |
| .container{{max-width:960px;margin:0 auto;padding:24px}} | |
| .cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}} | |
| .card{{background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;padding:16px}} | |
| .card .value{{font-size:28px;font-weight:700;margin:4px 0}} | |
| .card .label{{font-size:13px;color:#94a3b8}} | |
| .green{{color:#22c55e}}.yellow{{color:#f59e0b}}.red{{color:#ef4444}}.blue{{color:#6c63ff}} | |
| table{{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;overflow:hidden}} | |
| th{{background:#242836;font-weight:600;text-align:left;padding:10px 12px;font-size:12px;color:#94a3b8;text-transform:uppercase}} | |
| td{{padding:8px 12px;border-top:1px solid #2a2d3a;font-size:14px}}tr:hover td{{background:rgba(108,99,255,.05)}} | |
| .api-url{{font-family:monospace;font-size:12px;color:#6c63ff}} | |
| .refresh{{font-size:12px;color:#64748b;text-align:center;margin-top:24px}} | |
| </style> | |
| <meta http-equiv="refresh" content="30"> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1><span class="status-dot {'status-ok' if healthy else 'status-degraded'}"></span>号池健康监控</h1> | |
| <div style="font-size:13px;color:#94a3b8">v{app_version} · 30s 自动刷新</div> | |
| </div> | |
| <div class="container"> | |
| <div class="cards"> | |
| <div class="card"><div class="label">号池状态</div><div class="value {'green' if healthy else 'yellow'}">{'正常' if healthy else '异常'}</div></div> | |
| <div class="card"><div class="label">当前账号</div><div class="value blue">{stats['total']}</div></div> | |
| <div class="card"><div class="label">累计入库</div><div class="value">{stats['cumulative_total']}</div></div> | |
| <div class="card"><div class="label">可用账号</div><div class="value green">{stats['active']}</div></div> | |
| <div class="card"><div class="label">无限额</div><div class="value">{stats['unlimited_quota_count']}</div></div> | |
| <div class="card"><div class="label">剩余额度</div><div class="value">{stats['total_quota']}</div></div> | |
| <div class="card"><div class="label">限流</div><div class="value yellow">{stats['limited']}</div></div> | |
| <div class="card"><div class="label">异常</div><div class="value red">{stats['abnormal']}</div></div> | |
| <div class="card"><div class="label">禁用</div><div class="value">{stats['disabled']}</div></div> | |
| <div class="card"><div class="label">成功/失败</div><div class="value">{stats['total_success']}<span style="font-size:18px;color:#94a3b8">/</span><span class="red">{stats['total_fail']}</span></div></div> | |
| </div> | |
| <h2 style="margin-bottom:12px;font-size:16px">账号类型分布</h2> | |
| <table> | |
| <tr><th>类型</th><th>数量</th></tr> | |
| {''.join(f'<tr><td>{t}</td><td>{c}</td></tr>' for t,c in sorted(stats['by_type'].items()))} | |
| </table> | |
| <div class="refresh">JSON: <span class="api-url">/health?format=json</span></div> | |
| </div></body></html>""") | |
| return router | |