Initial project import
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
from api.app import create_app
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.auth_service import auth_service
|
||||
|
||||
from api.support import (
|
||||
require_admin,
|
||||
sanitize_cpa_pool,
|
||||
sanitize_cpa_pools,
|
||||
sanitize_sub2api_server,
|
||||
sanitize_sub2api_servers,
|
||||
)
|
||||
from services.account_service import account_service
|
||||
from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
|
||||
from services.oauth_login_service import OAuthLoginError, oauth_login_service
|
||||
from services.sub2api_service import (
|
||||
list_remote_accounts as sub2api_list_remote_accounts,
|
||||
list_remote_groups as sub2api_list_remote_groups,
|
||||
sub2api_config,
|
||||
sub2api_import_service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class UserKeyCreateRequest(BaseModel):
|
||||
name: str = ""
|
||||
|
||||
|
||||
class UserKeyUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
key: str | None = None
|
||||
|
||||
|
||||
class AccountCreateRequest(BaseModel):
|
||||
tokens: list[str] = Field(default_factory=list)
|
||||
accounts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AccountDeleteRequest(BaseModel):
|
||||
tokens: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AccountRefreshRequest(BaseModel):
|
||||
access_tokens: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AccountExportRequest(BaseModel):
|
||||
access_tokens: list[str] = Field(default_factory=list)
|
||||
format: Literal["json", "zip"] = "json"
|
||||
|
||||
|
||||
class AccountUpdateRequest(BaseModel):
|
||||
access_token: str = ""
|
||||
type: str | None = None
|
||||
status: str | None = None
|
||||
quota: int | None = None
|
||||
proxy: str | None = None
|
||||
|
||||
|
||||
class CPAPoolCreateRequest(BaseModel):
|
||||
name: str = ""
|
||||
base_url: str = ""
|
||||
secret_key: str = ""
|
||||
|
||||
|
||||
class CPAPoolUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
secret_key: str | None = None
|
||||
|
||||
|
||||
class CPAImportRequest(BaseModel):
|
||||
names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Sub2APIServerCreateRequest(BaseModel):
|
||||
name: str = ""
|
||||
base_url: str = ""
|
||||
email: str = ""
|
||||
password: str = ""
|
||||
api_key: str = ""
|
||||
group_id: str = ""
|
||||
|
||||
|
||||
class Sub2APIServerUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
api_key: str | None = None
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
class Sub2APIImportRequest(BaseModel):
|
||||
account_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OAuthLoginStartRequest(BaseModel):
|
||||
"""起始 OAuth 桥。email_hint 可选,仅用于让 OpenAI 登录页预填邮箱。"""
|
||||
email_hint: str = ""
|
||||
|
||||
|
||||
class OAuthLoginFinishRequest(BaseModel):
|
||||
"""提交 callback。callback 既可以是完整 URL 也可以只填 code。"""
|
||||
session_id: str = ""
|
||||
callback: str = ""
|
||||
|
||||
|
||||
def _account_payload_token(item: dict[str, Any]) -> str:
|
||||
return str(item.get("access_token") or item.get("accessToken") or "").strip()
|
||||
|
||||
|
||||
def _unique_tokens(tokens: list[str]) -> list[str]:
|
||||
return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
|
||||
|
||||
|
||||
def _download_timestamp() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def _safe_export_name(value: str, fallback: str) -> str:
|
||||
clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
||||
return (clean or fallback)[:80]
|
||||
|
||||
|
||||
def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
used_names: set[str] = set()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for index, item in enumerate(items, start=1):
|
||||
raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
|
||||
base_name = _safe_export_name(raw_name, f"account-{index:03d}")
|
||||
name = base_name
|
||||
suffix = 2
|
||||
while name in used_names:
|
||||
name = f"{base_name}-{suffix}"
|
||||
suffix += 1
|
||||
used_names.add(name)
|
||||
archive.writestr(
|
||||
f"{name}.json",
|
||||
json.dumps(item, ensure_ascii=False, indent=2) + "\n",
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def create_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/auth/users")
|
||||
async def list_user_keys(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"items": auth_service.list_keys(role="user")}
|
||||
|
||||
@router.post("/api/auth/users")
|
||||
async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
try:
|
||||
item, raw_key = auth_service.create_key(role="user", name=body.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
|
||||
|
||||
@router.post("/api/auth/users/{key_id}")
|
||||
async def update_user_key(
|
||||
key_id: str,
|
||||
body: UserKeyUpdateRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
require_admin(authorization)
|
||||
updates = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"name": body.name,
|
||||
"enabled": body.enabled,
|
||||
"key": body.key,
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
|
||||
try:
|
||||
item = auth_service.update_key(key_id, updates, role="user")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
|
||||
return {"item": item, "items": auth_service.list_keys(role="user")}
|
||||
|
||||
@router.delete("/api/auth/users/{key_id}")
|
||||
async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
if not auth_service.delete_key(key_id, role="user"):
|
||||
raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
|
||||
return {"items": auth_service.list_keys(role="user")}
|
||||
|
||||
@router.get("/api/accounts")
|
||||
async def get_accounts(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"items": account_service.list_accounts()}
|
||||
|
||||
@router.post("/api/accounts")
|
||||
async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
account_payloads = [item for item in body.accounts if isinstance(item, dict)]
|
||||
payload_tokens = [_account_payload_token(item) for item in account_payloads]
|
||||
tokens = _unique_tokens([*body.tokens, *payload_tokens])
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
||||
if account_payloads:
|
||||
result = account_service.add_account_items(account_payloads)
|
||||
payload_token_set = set(_unique_tokens(payload_tokens))
|
||||
extra_tokens = [token for token in tokens if token not in payload_token_set]
|
||||
if extra_tokens:
|
||||
extra_result = account_service.add_accounts(extra_tokens)
|
||||
result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
|
||||
result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
|
||||
else:
|
||||
result = account_service.add_accounts(tokens)
|
||||
refresh_result = account_service.refresh_accounts(tokens)
|
||||
return {
|
||||
**result,
|
||||
"refreshed": refresh_result.get("refreshed", 0),
|
||||
"errors": refresh_result.get("errors", []),
|
||||
"items": refresh_result.get("items", result.get("items", [])),
|
||||
}
|
||||
|
||||
@router.delete("/api/accounts")
|
||||
async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
||||
return account_service.delete_accounts(tokens)
|
||||
|
||||
@router.post("/api/accounts/refresh")
|
||||
async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
|
||||
if not access_tokens:
|
||||
access_tokens = account_service.list_tokens()
|
||||
if not access_tokens:
|
||||
raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
|
||||
|
||||
progress_id = str(uuid.uuid4())
|
||||
|
||||
async def _do_refresh():
|
||||
try:
|
||||
await run_in_threadpool(account_service.refresh_accounts, access_tokens, progress_id, False)
|
||||
except Exception as e:
|
||||
account_service.finish_refresh_progress(progress_id, error=str(e))
|
||||
|
||||
asyncio.create_task(_do_refresh())
|
||||
|
||||
return {"progress_id": progress_id}
|
||||
|
||||
@router.get("/api/accounts/refresh/progress/{progress_id}")
|
||||
async def get_refresh_progress(progress_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
progress = account_service.get_refresh_progress(progress_id)
|
||||
if progress is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "progress not found"})
|
||||
return progress
|
||||
|
||||
@router.post("/api/accounts/re-login")
|
||||
async def re_login_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
|
||||
"""对选中账号执行密码重新登录流程(密码登录→验证码登录→刷新token)。"""
|
||||
require_admin(authorization)
|
||||
access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
|
||||
if not access_tokens:
|
||||
raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
|
||||
|
||||
progress_id = str(uuid.uuid4())
|
||||
|
||||
async def _do_relogin():
|
||||
try:
|
||||
await run_in_threadpool(account_service.re_login_accounts, access_tokens, progress_id)
|
||||
except Exception as e:
|
||||
account_service.finish_relogin_progress(progress_id, error=str(e))
|
||||
|
||||
asyncio.create_task(_do_relogin())
|
||||
|
||||
return {"progress_id": progress_id}
|
||||
|
||||
@router.get("/api/accounts/re-login/progress/{progress_id}")
|
||||
async def get_relogin_progress(progress_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
progress = account_service.get_relogin_progress(progress_id)
|
||||
if progress is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "progress not found"})
|
||||
return progress
|
||||
|
||||
@router.post("/api/accounts/export")
|
||||
async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
access_tokens = _unique_tokens(body.access_tokens)
|
||||
items = account_service.build_export_items(access_tokens)
|
||||
if not items:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
|
||||
)
|
||||
|
||||
timestamp = _download_timestamp()
|
||||
if body.format == "zip":
|
||||
content = _account_zip_bytes(items)
|
||||
return Response(
|
||||
content,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
|
||||
)
|
||||
|
||||
payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
|
||||
return Response(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
|
||||
)
|
||||
|
||||
@router.post("/api/accounts/update")
|
||||
async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
access_token = str(body.access_token or "").strip()
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=400, detail={"error": "access_token is required"})
|
||||
updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota, "proxy": body.proxy}.items() if value is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
|
||||
account = account_service.update_account(access_token, updates, manual=True)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "account not found"})
|
||||
return {"item": account, "items": account_service.list_accounts()}
|
||||
|
||||
@router.post("/api/accounts/oauth/start")
|
||||
async def start_oauth_login(
|
||||
body: OAuthLoginStartRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""登记一次 PKCE 会话,返回可让用户浏览器打开的 authorize URL。"""
|
||||
require_admin(authorization)
|
||||
try:
|
||||
return await run_in_threadpool(oauth_login_service.start, body.email_hint)
|
||||
except OAuthLoginError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
|
||||
@router.post("/api/accounts/oauth/finish")
|
||||
async def finish_oauth_login(
|
||||
body: OAuthLoginFinishRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""收用户从浏览器抓回的 callback URL / code,换出 token 三件套并落盘。"""
|
||||
require_admin(authorization)
|
||||
# 入参日志:截断敏感字段,仅保留前几位,方便排错而不泄密
|
||||
cb_preview = (body.callback or "")[:80]
|
||||
sid_preview = (body.session_id or "")[:8]
|
||||
print(
|
||||
f"[oauth-login] finish called: session_id={sid_preview}..., callback_preview={cb_preview!r}",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
tokens = await run_in_threadpool(oauth_login_service.finish, body.session_id, body.callback)
|
||||
except OAuthLoginError as exc:
|
||||
print(f"[oauth-login] finish rejected: {exc}", flush=True)
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
|
||||
payload = {
|
||||
"access_token": tokens["access_token"],
|
||||
"refresh_token": tokens["refresh_token"],
|
||||
"id_token": tokens["id_token"],
|
||||
"source_type": "oauth_login",
|
||||
}
|
||||
add_result = await run_in_threadpool(account_service.add_account_items, [payload])
|
||||
refresh_result = await run_in_threadpool(
|
||||
account_service.refresh_accounts, [tokens["access_token"]]
|
||||
)
|
||||
return {
|
||||
**add_result,
|
||||
"refreshed": refresh_result.get("refreshed", 0),
|
||||
"errors": refresh_result.get("errors", []),
|
||||
"items": refresh_result.get("items", add_result.get("items", [])),
|
||||
}
|
||||
|
||||
@router.get("/api/cpa/pools")
|
||||
async def list_cpa_pools(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
||||
|
||||
@router.post("/api/cpa/pools")
|
||||
async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
if not body.base_url.strip():
|
||||
raise HTTPException(status_code=400, detail={"error": "base_url is required"})
|
||||
if not body.secret_key.strip():
|
||||
raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
|
||||
pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
|
||||
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
||||
|
||||
@router.post("/api/cpa/pools/{pool_id}")
|
||||
async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
||||
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
||||
|
||||
@router.delete("/api/cpa/pools/{pool_id}")
|
||||
async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
if not cpa_config.delete_pool(pool_id):
|
||||
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
||||
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
||||
|
||||
@router.get("/api/cpa/pools/{pool_id}/files")
|
||||
async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
pool = cpa_config.get_pool(pool_id)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
||||
return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
|
||||
|
||||
@router.post("/api/cpa/pools/{pool_id}/import")
|
||||
async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
pool = cpa_config.get_pool(pool_id)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
||||
try:
|
||||
job = cpa_import_service.start_import(pool, body.names)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
return {"import_job": job}
|
||||
|
||||
@router.get("/api/cpa/pools/{pool_id}/import")
|
||||
async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
pool = cpa_config.get_pool(pool_id)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
||||
return {"import_job": pool.get("import_job")}
|
||||
|
||||
@router.get("/api/sub2api/servers")
|
||||
async def list_sub2api_servers(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
||||
|
||||
@router.post("/api/sub2api/servers")
|
||||
async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
if not body.base_url.strip():
|
||||
raise HTTPException(status_code=400, detail={"error": "base_url is required"})
|
||||
has_login = body.email.strip() and body.password.strip()
|
||||
has_api_key = bool(body.api_key.strip())
|
||||
if not has_login and not has_api_key:
|
||||
raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
|
||||
server = sub2api_config.add_server(
|
||||
name=body.name,
|
||||
base_url=body.base_url,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
api_key=body.api_key,
|
||||
group_id=body.group_id,
|
||||
)
|
||||
return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
||||
|
||||
@router.post("/api/sub2api/servers/{server_id}")
|
||||
async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
||||
return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
||||
|
||||
@router.delete("/api/sub2api/servers/{server_id}")
|
||||
async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
if not sub2api_config.delete_server(server_id):
|
||||
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
||||
return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
||||
|
||||
@router.get("/api/sub2api/servers/{server_id}/groups")
|
||||
async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
server = sub2api_config.get_server(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
||||
try:
|
||||
groups = await run_in_threadpool(sub2api_list_remote_groups, server)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
||||
return {"server_id": server_id, "groups": groups}
|
||||
|
||||
@router.get("/api/sub2api/servers/{server_id}/accounts")
|
||||
async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
server = sub2api_config.get_server(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
||||
try:
|
||||
accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
||||
return {"server_id": server_id, "accounts": accounts}
|
||||
|
||||
@router.post("/api/sub2api/servers/{server_id}/import")
|
||||
async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
server = sub2api_config.get_server(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
||||
try:
|
||||
job = sub2api_import_service.start_import(server, body.account_ids)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
return {"import_job": job}
|
||||
|
||||
@router.get("/api/sub2api/servers/{server_id}/import")
|
||||
async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
server = sub2api_config.get_server(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
||||
return {"import_job": server.get("import_job")}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from api.image_inputs import parse_image_edit_request, read_image_sources
|
||||
from api.support import require_identity, resolve_image_base_url
|
||||
from services.content_filter import check_request, request_shape, request_text
|
||||
from services.editable_file_task_service import editable_file_task_service
|
||||
from services.log_service import LoggedCall
|
||||
from services.protocol import (
|
||||
anthropic_v1_messages,
|
||||
openai_v1_chat_complete,
|
||||
openai_v1_image_edit,
|
||||
openai_v1_image_generations,
|
||||
openai_v1_models,
|
||||
openai_v1_response,
|
||||
openai_search,
|
||||
)
|
||||
|
||||
|
||||
class ImageGenerationRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1)
|
||||
model: str = "gpt-image-2"
|
||||
n: int = Field(default=1, ge=1, le=4)
|
||||
size: str | None = None
|
||||
quality: str = "auto"
|
||||
response_format: str = "b64_json"
|
||||
history_disabled: bool = True
|
||||
stream: bool | None = None
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
model: str | None = None
|
||||
prompt: str | None = None
|
||||
n: int | None = None
|
||||
stream: bool | None = None
|
||||
modalities: list[str] | None = None
|
||||
messages: list[dict[str, object]] | None = None
|
||||
|
||||
|
||||
class ResponseCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
model: str | None = None
|
||||
input: object | None = None
|
||||
tools: list[dict[str, object]] | None = None
|
||||
tool_choice: object | None = None
|
||||
stream: bool | None = None
|
||||
|
||||
|
||||
class AnthropicMessageRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
model: str | None = None
|
||||
messages: list[dict[str, object]] | None = None
|
||||
system: object | None = None
|
||||
stream: bool | None = None
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class EditableFileTaskRequest(BaseModel):
|
||||
prompt: str = ""
|
||||
base64_images: list[str] = Field(default_factory=list)
|
||||
client_task_id: str | None = None
|
||||
|
||||
|
||||
async def filter_or_log(call: LoggedCall, text: str) -> None:
|
||||
try:
|
||||
await run_in_threadpool(check_request, text)
|
||||
except HTTPException as exc:
|
||||
call.log("调用失败", status="failed", error=str(exc.detail))
|
||||
raise
|
||||
|
||||
|
||||
def create_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/v1/models")
|
||||
async def list_models(authorization: str | None = Header(default=None)):
|
||||
require_identity(authorization)
|
||||
try:
|
||||
return await run_in_threadpool(openai_v1_models.list_models)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
||||
|
||||
@router.post("/v1/images/generations")
|
||||
async def generate_images(
|
||||
body: ImageGenerationRequest,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
identity = require_identity(authorization)
|
||||
payload = body.model_dump(mode="python")
|
||||
payload["base_url"] = resolve_image_base_url(request)
|
||||
call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
|
||||
await filter_or_log(call, body.prompt)
|
||||
return await call.run(openai_v1_image_generations.handle, payload)
|
||||
|
||||
@router.post("/v1/images/edits")
|
||||
async def edit_images(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
identity = require_identity(authorization)
|
||||
payload, image_sources, mask_sources = await parse_image_edit_request(request)
|
||||
prompt = str(payload["prompt"])
|
||||
model = str(payload["model"])
|
||||
call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
|
||||
await filter_or_log(call, prompt)
|
||||
payload["images"] = await read_image_sources(image_sources)
|
||||
if mask_sources:
|
||||
payload["mask"] = await read_image_sources(mask_sources)
|
||||
payload["base_url"] = resolve_image_base_url(request)
|
||||
return await call.run(openai_v1_image_edit.handle, payload)
|
||||
|
||||
@router.post("/v1/chat/completions")
|
||||
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
|
||||
identity = require_identity(authorization)
|
||||
payload = body.model_dump(mode="python")
|
||||
model = str(payload.get("model") or "auto")
|
||||
request_preview = request_text(payload.get("prompt"), payload.get("messages"))
|
||||
call = LoggedCall(
|
||||
identity,
|
||||
"/v1/chat/completions",
|
||||
model,
|
||||
"文本生成",
|
||||
request_text=request_preview,
|
||||
request_shape=request_shape(payload.get("messages")),
|
||||
)
|
||||
await filter_or_log(call, request_preview)
|
||||
return await call.run(openai_v1_chat_complete.handle, payload)
|
||||
|
||||
@router.post("/v1/responses")
|
||||
async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
|
||||
identity = require_identity(authorization)
|
||||
payload = body.model_dump(mode="python")
|
||||
model = str(payload.get("model") or "auto")
|
||||
request_preview = request_text(payload.get("input"), payload.get("instructions"))
|
||||
call = LoggedCall(
|
||||
identity,
|
||||
"/v1/responses",
|
||||
model,
|
||||
"Responses",
|
||||
request_text=request_preview,
|
||||
request_shape=request_shape(payload.get("input")),
|
||||
)
|
||||
await filter_or_log(call, request_preview)
|
||||
return await call.run(openai_v1_response.handle, payload)
|
||||
|
||||
@router.post("/v1/messages")
|
||||
async def create_message(
|
||||
body: AnthropicMessageRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="x-api-key"),
|
||||
anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
|
||||
):
|
||||
identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
|
||||
payload = body.model_dump(mode="python")
|
||||
model = str(payload.get("model") or "auto")
|
||||
request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
|
||||
call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
|
||||
await filter_or_log(call, request_preview)
|
||||
return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
|
||||
|
||||
@router.post("/v1/search")
|
||||
async def search(body: SearchRequest, authorization: str | None = Header(default=None)):
|
||||
identity = require_identity(authorization)
|
||||
call = LoggedCall(identity, "/v1/search", openai_search.MODEL, "搜索", request_text=body.prompt)
|
||||
await filter_or_log(call, body.prompt)
|
||||
return await call.run(openai_search.handle, body.model_dump(mode="python"))
|
||||
|
||||
@router.get("/v1/editable-file-tasks")
|
||||
async def list_editable_file_tasks(ids: str = "", authorization: str | None = Header(default=None)):
|
||||
identity = require_identity(authorization)
|
||||
task_ids = [item.strip() for item in ids.split(",") if item.strip()]
|
||||
return await run_in_threadpool(editable_file_task_service.list_tasks, identity, task_ids)
|
||||
|
||||
@router.get("/files/{file_path:path}")
|
||||
async def download_editable_file(file_path: str):
|
||||
try:
|
||||
path = await run_in_threadpool(editable_file_task_service.public_file_path, file_path)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail={"error": "file not found"}) from exc
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
@router.post("/v1/ppt/generations")
|
||||
async def create_ppt_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)):
|
||||
identity = require_identity(authorization)
|
||||
await filter_or_log(LoggedCall(identity, "/v1/ppt/generations", "gpt-5-5-thinking", "PPT生成任务", request_text=body.prompt), body.prompt)
|
||||
return await run_in_threadpool(
|
||||
editable_file_task_service.submit_ppt,
|
||||
identity,
|
||||
client_task_id=body.client_task_id or "",
|
||||
prompt=body.prompt,
|
||||
base64_images=body.base64_images,
|
||||
base_url=resolve_image_base_url(request),
|
||||
)
|
||||
|
||||
@router.post("/v1/psd/generations")
|
||||
async def create_psd_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)):
|
||||
identity = require_identity(authorization)
|
||||
await filter_or_log(LoggedCall(identity, "/v1/psd/generations", "gpt-5-5-thinking", "PSD生成任务", request_text=body.prompt), body.prompt)
|
||||
return await run_in_threadpool(
|
||||
editable_file_task_service.submit_psd,
|
||||
identity,
|
||||
client_task_id=body.client_task_id or "",
|
||||
prompt=body.prompt,
|
||||
base64_images=body.base64_images,
|
||||
base_url=resolve_image_base_url(request),
|
||||
)
|
||||
|
||||
return router
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Event
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from api import accounts, ai, image_tasks, system
|
||||
from api.errors import install_exception_handlers
|
||||
from api.support import resolve_web_asset, start_limited_account_watcher
|
||||
from services.backup_service import backup_service
|
||||
from services.config import config
|
||||
from services.image_service import start_image_cleanup_scheduler
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app_version = config.app_version
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
stop_event = Event()
|
||||
thread = start_limited_account_watcher(stop_event)
|
||||
cleanup_thread = start_image_cleanup_scheduler(stop_event)
|
||||
backup_service.start()
|
||||
config.cleanup_old_images()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_event.set()
|
||||
thread.join(timeout=1)
|
||||
cleanup_thread.join(timeout=1)
|
||||
backup_service.stop()
|
||||
|
||||
app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan)
|
||||
install_exception_handlers(app)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(ai.create_router())
|
||||
app.include_router(accounts.create_router())
|
||||
app.include_router(image_tasks.create_router())
|
||||
app.include_router(system.create_router(app_version))
|
||||
|
||||
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)
|
||||
async def serve_web(full_path: str):
|
||||
asset = resolve_web_asset(full_path)
|
||||
if asset is not None:
|
||||
return FileResponse(asset)
|
||||
if full_path.strip("/").startswith("_next/"):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
fallback = resolve_web_asset("")
|
||||
if fallback is None:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return FileResponse(fallback)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from services.protocol.error_response import anthropic_error_response, openai_error_response
|
||||
|
||||
|
||||
def _is_openai_compatible_path(path: str) -> bool:
|
||||
return path == "/v1" or path.startswith("/v1/")
|
||||
|
||||
|
||||
def _is_anthropic_messages_path(path: str) -> bool:
|
||||
return path == "/v1/messages"
|
||||
|
||||
|
||||
def _compatible_error_response(
|
||||
request: Request,
|
||||
detail: object,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> JSONResponse:
|
||||
if _is_anthropic_messages_path(request.url.path):
|
||||
return anthropic_error_response(detail, status_code, headers=headers)
|
||||
return openai_error_response(detail, status_code, headers=headers)
|
||||
|
||||
|
||||
def install_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
if _is_openai_compatible_path(request.url.path):
|
||||
return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": jsonable_encoder(exc.detail)},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
if _is_openai_compatible_path(request.url.path):
|
||||
return _compatible_error_response(request, exc.errors(), 422)
|
||||
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
||||
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, TypeGuard
|
||||
from urllib.parse import unquote, unquote_to_bytes, urlparse
|
||||
|
||||
from curl_cffi import requests
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from services.proxy_service import proxy_settings
|
||||
|
||||
ImageInput = tuple[bytes, str, str]
|
||||
ImageSource = str | UploadFile | ImageInput
|
||||
|
||||
MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024
|
||||
IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"}
|
||||
MASK_REFERENCE_FIELDS = {"mask", "mask[]"}
|
||||
|
||||
|
||||
def _clean(value: object, default: str = "") -> str:
|
||||
"""清理字符串:转换为字符串并去掉首尾空白。"""
|
||||
text = str(value if value is not None else default).strip()
|
||||
return text or default
|
||||
|
||||
|
||||
def _is_upload(value: object) -> TypeGuard[UploadFile]:
|
||||
"""识别上传文件:兼容 Starlette 表单返回的 UploadFile。"""
|
||||
return isinstance(value, UploadFile)
|
||||
|
||||
|
||||
def _parse_bool(value: object) -> bool | None:
|
||||
"""解析布尔字段:兼容 JSON 布尔值和表单字符串。"""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = _clean(value).lower()
|
||||
if text in {"true", "1", "yes", "y", "on"}:
|
||||
return True
|
||||
if text in {"false", "0", "no", "n", "off"}:
|
||||
return False
|
||||
raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"})
|
||||
|
||||
|
||||
def _parse_count(value: object) -> int:
|
||||
"""解析生成数量:保持图片接口的 1 到 4 限制。"""
|
||||
try:
|
||||
count = int(value or 1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
||||
if count < 1 or count > 4:
|
||||
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
||||
return count
|
||||
|
||||
|
||||
def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
||||
"""构造图片编辑载荷:从表单或 JSON 字段提取通用参数。"""
|
||||
prompt = _clean(fields.get("prompt"))
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"model": _clean(fields.get("model"), "gpt-image-2"),
|
||||
"n": _parse_count(fields.get("n")),
|
||||
"size": _clean(fields.get("size")) or None,
|
||||
"quality": _clean(fields.get("quality"), "auto"),
|
||||
"response_format": _clean(fields.get("response_format"), "b64_json"),
|
||||
"stream": _parse_bool(fields.get("stream")),
|
||||
}
|
||||
if "client_task_id" in fields:
|
||||
payload["client_task_id"] = _clean(fields.get("client_task_id"))
|
||||
return payload
|
||||
|
||||
|
||||
def _json_reference_value(value: object) -> object:
|
||||
"""解析表单图片引用:支持把 images 字段写成 JSON 字符串。"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
if not text or text[0] not in "[{":
|
||||
return value
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput:
|
||||
try:
|
||||
data = base64.b64decode(str(value).strip(), validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
||||
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
||||
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
|
||||
return data, filename, mime_type
|
||||
|
||||
|
||||
def _source_from_object(value: dict[str, Any]) -> list[ImageSource]:
|
||||
"""提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。"""
|
||||
has_url = "image_url" in value or "url" in value
|
||||
if value.get("file_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "file_id image references are not supported; use image_url instead"},
|
||||
)
|
||||
inline = value.get("b64_json") or value.get("base64")
|
||||
if inline:
|
||||
filename = _clean(value.get("filename") or value.get("file_name"), "image.png")
|
||||
mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png")
|
||||
return [_decode_base64_image(inline, filename, mime_type)]
|
||||
if not has_url:
|
||||
raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"})
|
||||
image_url = value.get("image_url", value.get("url"))
|
||||
if isinstance(image_url, dict):
|
||||
image_url = image_url.get("url")
|
||||
return _sources_from_value(image_url)
|
||||
|
||||
|
||||
def _sources_from_value(value: object) -> list[ImageSource]:
|
||||
"""展开图片引用:把字符串、数组和对象统一成图片来源列表。"""
|
||||
value = _json_reference_value(value)
|
||||
if _is_upload(value):
|
||||
return [value]
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.lower().startswith(("data:", "http://", "https://")):
|
||||
return [text]
|
||||
return [_decode_base64_image(text, "image.png", "image/png")]
|
||||
if isinstance(value, list):
|
||||
sources: list[ImageSource] = []
|
||||
for item in value:
|
||||
sources.extend(_sources_from_value(item))
|
||||
return sources
|
||||
if isinstance(value, dict):
|
||||
return _source_from_object(value)
|
||||
if value is None:
|
||||
return []
|
||||
raise HTTPException(status_code=400, detail={"error": "invalid image reference"})
|
||||
|
||||
|
||||
def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]:
|
||||
"""读取 JSON 图片引用:优先支持官方 images 数组字段。"""
|
||||
sources: list[ImageSource] = []
|
||||
for key in ("images", "image", "image_url"):
|
||||
if key in body:
|
||||
sources.extend(_sources_from_value(body.get(key)))
|
||||
return sources
|
||||
|
||||
|
||||
def _json_mask_sources(body: dict[str, Any]) -> list[ImageSource]:
|
||||
"""读取 JSON mask 引用。"""
|
||||
mask = body.get("mask")
|
||||
if mask is not None:
|
||||
return _sources_from_value(mask)
|
||||
return []
|
||||
|
||||
|
||||
async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource], list[ImageSource]]:
|
||||
"""解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。
|
||||
|
||||
返回 (payload, image_sources, mask_sources)
|
||||
"""
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type == "application/json":
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"})
|
||||
return _payload_from_fields(body), _json_image_sources(body), _json_mask_sources(body)
|
||||
|
||||
form = await request.form()
|
||||
fields: dict[str, Any] = {}
|
||||
for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
|
||||
value = form.get(key)
|
||||
if isinstance(value, str):
|
||||
fields[key] = value
|
||||
sources: list[ImageSource] = []
|
||||
mask_sources: list[ImageSource] = []
|
||||
for key, value in form.multi_items():
|
||||
if key in IMAGE_REFERENCE_FIELDS:
|
||||
sources.extend(_sources_from_value(value))
|
||||
elif key in MASK_REFERENCE_FIELDS:
|
||||
mask_sources.extend(_sources_from_value(value))
|
||||
return _payload_from_fields(fields), sources, mask_sources
|
||||
|
||||
|
||||
def _extension_from_mime(mime_type: str) -> str:
|
||||
"""推导图片扩展名:把 MIME 类型转换为常见文件后缀。"""
|
||||
subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png"
|
||||
if subtype == "jpeg":
|
||||
return "jpg"
|
||||
return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png"
|
||||
|
||||
|
||||
def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
|
||||
"""生成安全文件名:清理 URL 文件名并补齐扩展名。"""
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
|
||||
if not cleaned:
|
||||
cleaned = fallback
|
||||
if "." not in cleaned:
|
||||
cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}"
|
||||
return cleaned
|
||||
|
||||
|
||||
def _decode_data_url(url: str) -> ImageInput:
|
||||
"""解码 data URL:把内联图片转成标准图片输入元组。"""
|
||||
header, separator, payload = url.partition(",")
|
||||
if not separator:
|
||||
raise HTTPException(status_code=400, detail={"error": "invalid data image URL"})
|
||||
mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
|
||||
if not mime_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
||||
try:
|
||||
data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
|
||||
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
||||
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
|
||||
return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
|
||||
|
||||
|
||||
def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
|
||||
"""识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
|
||||
header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
|
||||
guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
|
||||
if header_type.startswith("image/"):
|
||||
return header_type
|
||||
if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
||||
if guessed_type.startswith("image/"):
|
||||
return guessed_type
|
||||
if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
|
||||
return "image/png"
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
||||
|
||||
|
||||
def _filename_from_url(parsed_path: str, mime_type: str) -> str:
|
||||
"""生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
|
||||
raw_name = PurePosixPath(unquote(parsed_path)).name
|
||||
return _safe_filename(raw_name, mime_type, "image_url")
|
||||
|
||||
|
||||
def _download_image_url(url: str) -> ImageInput:
|
||||
"""下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
|
||||
source = _clean(url)
|
||||
if source.startswith("data:"):
|
||||
return _decode_data_url(source)
|
||||
parsed = urlparse(source)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
|
||||
try:
|
||||
response = requests.get(
|
||||
source,
|
||||
headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
|
||||
timeout=60,
|
||||
allow_redirects=True,
|
||||
**proxy_settings.build_session_kwargs(),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
|
||||
content_length = _clean(response.headers.get("content-length"))
|
||||
if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
|
||||
data = response.content
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
|
||||
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
||||
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
|
||||
mime_type = _response_mime_type(response, parsed.path)
|
||||
return data, _filename_from_url(parsed.path, mime_type), mime_type
|
||||
|
||||
|
||||
async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
|
||||
"""读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
|
||||
images: list[ImageInput] = []
|
||||
for source in sources:
|
||||
if isinstance(source, tuple):
|
||||
images.append(source)
|
||||
continue
|
||||
if _is_upload(source):
|
||||
try:
|
||||
image_data = await source.read()
|
||||
finally:
|
||||
await source.close()
|
||||
if not image_data:
|
||||
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
||||
images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
|
||||
continue
|
||||
images.append(await run_in_threadpool(_download_image_url, source))
|
||||
if not images:
|
||||
raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
|
||||
return images
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.image_inputs import parse_image_edit_request, read_image_sources
|
||||
from api.support import require_identity, resolve_image_base_url
|
||||
from services.content_filter import check_request
|
||||
from services.image_task_service import image_task_service
|
||||
from services.log_service import LoggedCall
|
||||
|
||||
|
||||
class ImageGenerationTaskRequest(BaseModel):
|
||||
client_task_id: str = Field(..., min_length=1)
|
||||
prompt: str = Field(..., min_length=1)
|
||||
model: str = "gpt-image-2"
|
||||
size: str | None = None
|
||||
quality: str = "auto"
|
||||
|
||||
|
||||
class ResumePollRequest(BaseModel):
|
||||
extra_timeout_secs: float = Field(default=30.0, ge=5.0, le=120.0)
|
||||
|
||||
|
||||
def _parse_task_ids(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
async def filter_or_log(call: LoggedCall, text: str) -> None:
|
||||
try:
|
||||
await run_in_threadpool(check_request, text)
|
||||
except HTTPException as exc:
|
||||
call.log("调用失败", status="failed", error=str(exc.detail))
|
||||
raise
|
||||
|
||||
|
||||
def create_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/image-tasks")
|
||||
async def list_image_tasks(
|
||||
ids: str = Query(default=""),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
identity = require_identity(authorization)
|
||||
return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
|
||||
|
||||
@router.post("/api/image-tasks/generations")
|
||||
async def create_generation_task(
|
||||
body: ImageGenerationTaskRequest,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
identity = require_identity(authorization)
|
||||
await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
image_task_service.submit_generation,
|
||||
identity,
|
||||
client_task_id=body.client_task_id,
|
||||
prompt=body.prompt,
|
||||
model=body.model,
|
||||
size=body.size,
|
||||
quality=body.quality,
|
||||
base_url=resolve_image_base_url(request),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
|
||||
@router.post("/api/image-tasks/edits")
|
||||
async def create_edit_task(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
identity = require_identity(authorization)
|
||||
payload, image_sources, mask_sources = await parse_image_edit_request(request)
|
||||
client_task_id = str(payload.get("client_task_id") or "").strip()
|
||||
if not client_task_id:
|
||||
raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
|
||||
prompt = str(payload["prompt"])
|
||||
model = str(payload["model"])
|
||||
await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
|
||||
images = await read_image_sources(image_sources)
|
||||
masks = await read_image_sources(mask_sources) if mask_sources else None
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
image_task_service.submit_edit,
|
||||
identity,
|
||||
client_task_id=client_task_id,
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
size=payload["size"],
|
||||
quality=payload["quality"],
|
||||
base_url=resolve_image_base_url(request),
|
||||
images=images,
|
||||
masks=masks,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
|
||||
@router.post("/api/image-tasks/{task_id}/resume-poll")
|
||||
async def resume_image_poll(
|
||||
task_id: str,
|
||||
body: ResumePollRequest,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
identity = require_identity(authorization)
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
image_task_service.resume_poll,
|
||||
identity,
|
||||
task_id,
|
||||
body.extra_timeout_secs,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
|
||||
return router
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
|
||||
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"}
|
||||
return None
|
||||
|
||||
|
||||
def require_identity(authorization: str | None) -> dict[str, object]:
|
||||
token = extract_bearer_token(authorization)
|
||||
identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
|
||||
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:
|
||||
return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}"
|
||||
|
||||
|
||||
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:
|
||||
interval_seconds = config.refresh_account_interval_minute * 60
|
||||
|
||||
def worker() -> None:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
limited_tokens = account_service.list_limited_tokens()
|
||||
normal_tokens = account_service.list_normal_tokens()
|
||||
expiring_tokens = account_service.list_expiring_access_tokens()
|
||||
keepalive_tokens = account_service.list_refresh_token_keepalive_tokens()
|
||||
tokens = list(dict.fromkeys([*limited_tokens, *normal_tokens, *expiring_tokens]))
|
||||
expiring_token_set = set(expiring_tokens)
|
||||
keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set]
|
||||
if tokens:
|
||||
print(
|
||||
"[account-watcher] checking "
|
||||
f"{len(limited_tokens)} limited accounts, "
|
||||
f"{len(normal_tokens)} normal accounts, "
|
||||
f"{len(expiring_tokens)} expiring access tokens"
|
||||
)
|
||||
account_service.refresh_accounts(tokens)
|
||||
if keepalive_tokens:
|
||||
print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens")
|
||||
result = account_service.keepalive_refresh_tokens(keepalive_tokens)
|
||||
if result.get("errors"):
|
||||
print(f"[account-watcher] keepalive errors: {result['errors']}")
|
||||
except Exception as exc:
|
||||
print(f"[account-watcher] fail {exc}")
|
||||
stop_event.wait(interval_seconds)
|
||||
|
||||
thread = Thread(target=worker, name="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
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
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 proxy_settings, test_clearance, test_proxy
|
||||
|
||||
|
||||
class SettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ProxyTestRequest(BaseModel):
|
||||
url: str = ""
|
||||
|
||||
|
||||
class ClearanceTestRequest(BaseModel):
|
||||
target_url: str = "https://chatgpt.com"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@router.post("/auth/login")
|
||||
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"),
|
||||
}
|
||||
|
||||
@router.get("/version")
|
||||
async def get_version():
|
||||
return {"version": app_version}
|
||||
|
||||
@router.get("/api/settings")
|
||||
async def get_settings(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"config": config.get()}
|
||||
|
||||
@router.get("/api/third-party-apps")
|
||||
async def get_third_party_apps(authorization: str | None = Header(default=None)):
|
||||
require_identity(authorization)
|
||||
return {"third_party_apps": config.get_third_party_apps_settings()}
|
||||
|
||||
@router.post("/api/settings")
|
||||
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
|
||||
|
||||
@router.get("/api/images")
|
||||
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())
|
||||
|
||||
@router.get("/images/{image_path:path}", include_in_schema=False)
|
||||
async def get_image(image_path: str):
|
||||
return get_image_response(image_path)
|
||||
|
||||
@router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
|
||||
async def get_image_thumbnail(image_path: str):
|
||||
return get_thumbnail_response(image_path)
|
||||
|
||||
@router.post("/api/images/delete")
|
||||
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)
|
||||
|
||||
@router.post("/api/images/download")
|
||||
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"'},
|
||||
)
|
||||
|
||||
@router.get("/api/images/download/{image_path:path}")
|
||||
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)
|
||||
|
||||
@router.get("/api/logs")
|
||||
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())}
|
||||
|
||||
@router.post("/api/logs/delete")
|
||||
async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return log_service.delete(body.ids)
|
||||
|
||||
@router.post("/api/proxy/test")
|
||||
async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"result": await run_in_threadpool(test_proxy, (body.url or "").strip())}
|
||||
|
||||
@router.get("/api/proxy/runtime")
|
||||
async def get_proxy_runtime_endpoint(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {
|
||||
"runtime": config.get_public_proxy_runtime_settings(),
|
||||
"status": proxy_settings.get_runtime_status(),
|
||||
}
|
||||
|
||||
@router.post("/api/proxy/runtime")
|
||||
async def save_proxy_runtime_endpoint(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
try:
|
||||
config.update({"proxy_runtime": body.model_dump(mode="python")})
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
return {
|
||||
"runtime": config.get_public_proxy_runtime_settings(),
|
||||
"status": proxy_settings.get_runtime_status(),
|
||||
}
|
||||
|
||||
@router.post("/api/proxy/clearance/test")
|
||||
async def test_proxy_clearance_endpoint(body: ClearanceTestRequest, authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"result": await run_in_threadpool(test_clearance, body.target_url)}
|
||||
|
||||
@router.get("/api/storage/info")
|
||||
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(),
|
||||
}
|
||||
|
||||
@router.post("/api/backup/test")
|
||||
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
|
||||
|
||||
@router.post("/api/image-storage/test")
|
||||
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)}
|
||||
|
||||
@router.post("/api/image-storage/sync")
|
||||
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
|
||||
|
||||
@router.get("/api/backups")
|
||||
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
|
||||
|
||||
@router.post("/api/backups/run")
|
||||
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
|
||||
|
||||
@router.post("/api/backups/delete")
|
||||
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
|
||||
|
||||
@router.get("/api/backups/detail")
|
||||
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
|
||||
|
||||
@router.get("/api/backups/download")
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/images/tags")
|
||||
async def list_image_tags(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return {"tags": get_all_tags()}
|
||||
|
||||
@router.post("/api/images/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}
|
||||
|
||||
@router.delete("/api/images/tags/{tag}")
|
||||
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}
|
||||
|
||||
@router.get("/api/images/storage")
|
||||
async def get_image_storage(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return storage_stats()
|
||||
|
||||
@router.post("/api/images/storage/compress")
|
||||
async def compress_all_images(authorization: str | None = Header(default=None)):
|
||||
require_admin(authorization)
|
||||
return await run_in_threadpool(compress_images)
|
||||
|
||||
@router.post("/api/images/storage/cleanup-to-target")
|
||||
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)
|
||||
|
||||
@router.get("/health", response_model=None)
|
||||
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
|
||||
|
||||
stats_json = {
|
||||
"status": "ok" if healthy else "degraded",
|
||||
"healthy": healthy,
|
||||
"version": app_version,
|
||||
"storage": {"backend": storage.get_backend_info(), "health": storage_health},
|
||||
"proxy_runtime": proxy_settings.get_runtime_status(),
|
||||
"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['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
|
||||
Reference in New Issue
Block a user