Initial project import

This commit is contained in:
XM
2026-09-18 03:46:58 +08:00
commit b600da4b76
209 changed files with 45904 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
from api.app import create_app
+537
View File
@@ -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
+217
View File
@@ -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
View File
@@ -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
+46
View File
@@ -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())})
+309
View File
@@ -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
+120
View File
@@ -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
View File
@@ -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
View File
@@ -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