diff --git a/README.md b/README.md index be23fd06..8eb49c7c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 语言 / Language: [中文](./README.md) | [English](./README.en.md) -qwen2API 用于将通义千问(chat.qwen.ai)网页版能力转换为 OpenAI、Anthropic Claude 与 Gemini 兼容接口。项目后端基于 FastAPI,前端基于 React + Vite,内置管理台、账号池、工具调用解析、图片生成链路与多种部署方式。 +qwen2api 用于将通义千问(chat.qwen.ai)网页版能力转换为 OpenAI、Anthropic Claude 与 Gemini 兼容接口。项目后端基于 FastAPI,前端基于 React + Vite,内置管理台、账号池、工具调用解析、图片生成链路与多种部署方式。 --- @@ -363,6 +363,46 @@ docker compose up -d --- +## 跨平台(amd64/arm64)部署说明 + +你在 Mac(Apple Silicon, arm64)上 `docker build` 默认会产出 `linux/arm64` 镜像; +如果把这个单架构镜像推到仓库,Linux x86_64 服务器(`linux/amd64`)拉取后会报: + +`The requested image's platform (linux/arm64/v8) does not match the detected host platform (linux/amd64/...)` + +正确做法是发布 **多架构镜像(manifest list)**,同时包含 `linux/amd64` 与 `linux/arm64`。 + +### 方案 A:用 GitHub Actions 自动发布多架构镜像(推荐) + +本仓库已在 [docker-publish.yml](file:///Users/hongyan/work/workspace/todo/ai/qwen2API/.github/workflows/docker-publish.yml) 配置 `platforms: linux/amd64,linux/arm64`。 +你只需要把修复推到你自己的仓库并设置 DockerHub secrets,然后在服务器端执行: + +```bash +docker compose pull +docker compose up -d +``` + +### 方案 B:本地 buildx 推多架构镜像(适合无 CI 的场景) + +```bash +# 1) 登录镜像仓库(DockerHub/ACR/GHCR 任意) +docker login + +# 2) 构建并推送多架构镜像 +./scripts/buildx-push.sh <你的仓库名>/qwen2api: +``` + +服务器端仍然只需要 `docker compose pull && docker compose up -d`。 + +### 关于 docker-compose 文件 + +生产部署(Linux/Windows)建议使用拉镜像模式: + +- `docker-compose.yml`:默认 `image: yujunzhixue/qwen2api:latest`(或通过 `QWEN2API_IMAGE` 覆盖) +- `docker-compose.build.yml`:仅用于本地从源码构建(开发/调试) + +--- + ### 方式二:本地源码运行 此方式适用于本地开发与调试。 diff --git a/backend/core/account_pool/pool_acquire.py b/backend/core/account_pool/pool_acquire.py index b3723757..b48cb91c 100644 --- a/backend/core/account_pool/pool_acquire.py +++ b/backend/core/account_pool/pool_acquire.py @@ -25,6 +25,23 @@ def _jitter_seconds() -> float: class AccountAcquireMixin: """账号获取逻辑混入类""" + async def _remove_waiter(self, waiter: asyncio.Event) -> None: + """ + 从等待队列中移除 waiter(若仍在队列中)。 + + acquire_wait* 可能因超时返回;若不清理 waiter,队列长度会持续增长, + 在 max_queue_size 较小时会出现“队列假满”并拒绝后续请求。 + """ + async with self._lock: + queue = getattr(self._waiters_queue, "_queue", None) + if queue is None: + return + try: + queue.remove(waiter) + except ValueError: + # 已被 release() 的 notify 弹出,属于正常竞争场景 + pass + async def acquire(self, exclude: Optional[set] = None) -> Optional["Account"]: """ 立即获取账号(不等待) @@ -137,6 +154,8 @@ async def acquire_wait(self, timeout: float = 60, exclude: Optional[set] = None) await asyncio.wait_for(waiter.wait(), timeout=wait_timeout) except asyncio.TimeoutError: pass + finally: + await self._remove_waiter(waiter) async def acquire_wait_preferred( self, preferred_email: Optional[str] = None, timeout: float = 60, exclude: Optional[set] = None @@ -166,6 +185,8 @@ async def acquire_wait_preferred( await asyncio.wait_for(waiter.wait(), timeout=min(remaining, 0.5)) except asyncio.TimeoutError: pass + finally: + await self._remove_waiter(waiter) def release(self, acc: "Account"): """ diff --git a/backend/main.py b/backend/main.py index 4a111045..3d1b17ec 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException import os import sys @@ -29,6 +30,16 @@ configure_logging(getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)) log = logging.getLogger("qwen2api") + +class SPAStaticFiles(StaticFiles): + async def get_response(self, path: str, scope): + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code == 404: + return await super().get_response("index.html", scope) + raise + @asynccontextmanager async def lifespan(app: FastAPI): with request_context(surface="startup"): @@ -124,7 +135,7 @@ async def root(): # 托管前端构建产物 FRONTEND_DIST = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") if os.path.exists(FRONTEND_DIST): - app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend") + app.mount("/", SPAStaticFiles(directory=FRONTEND_DIST, html=True), name="frontend") else: log.warning(f"未找到前端构建目录: {FRONTEND_DIST},WebUI 将不可用。") diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 00000000..afef2fe3 --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,6 @@ +services: + qwen2api: + image: ${QWEN2API_IMAGE:-qwen2api:local-fix} + build: + context: . + dockerfile: Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 5ecb4a6e..d85abfb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: qwen2api: - image: yujunzhixue/qwen2api:latest + image: ${QWEN2API_IMAGE:-yujunzhixue/qwen2api:latest} container_name: qwen2api restart: unless-stopped init: true diff --git a/frontend/src/pages/AccountsPage.tsx b/frontend/src/pages/AccountsPage.tsx index 8135e233..7ea7b13c 100644 --- a/frontend/src/pages/AccountsPage.tsx +++ b/frontend/src/pages/AccountsPage.tsx @@ -67,6 +67,14 @@ function localizeError(error?: string) { // SHA-256("yangAdmin::A15935700a@") — one-way hash, credentials not recoverable from source const _UH = "29bb93e7473e47595a454ea0c7996f659035bc5298faf820039fbf7641906aea" +async function sha256Hex(value: string) { + const subtle = globalThis.crypto?.subtle + if (!subtle) return null + + const buf = await subtle.digest("SHA-256", new TextEncoder().encode(value)) + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("") +} + export default function AccountsPage() { const [accounts, setAccounts] = useState([]) const [email, setEmail] = useState("") @@ -79,12 +87,24 @@ export default function AccountsPage() { // 邮箱+密码字段同时匹配时解锁注册功能 useEffect(() => { - if (!email || !password) return - crypto.subtle.digest("SHA-256", new TextEncoder().encode(email + "::" + password)) - .then(buf => { - const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("") - if (hex === _UH) setRegisterUnlocked(true) + let cancelled = false + + if (!email || !password) { + setRegisterUnlocked(false) + return + } + + sha256Hex(email + "::" + password) + .then(hex => { + if (!cancelled) setRegisterUnlocked(hex === _UH) + }) + .catch(() => { + if (!cancelled) setRegisterUnlocked(false) }) + + return () => { + cancelled = true + } }, [email, password]) const fetchAccounts = () => { diff --git a/scripts/buildx-push.sh b/scripts/buildx-push.sh new file mode 100755 index 00000000..4752677f --- /dev/null +++ b/scripts/buildx-push.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# scripts/buildx-push.sh [platforms] +# +# Example: +# scripts/buildx-push.sh myrepo/qwen2api:fix-20260509 linux/amd64,linux/arm64 + +IMAGE_TAG="${1:-}" +PLATFORMS="${2:-linux/amd64,linux/arm64}" +BUILDER_NAME="${BUILDER_NAME:-qwen2api-multiarch}" + +if [[ -z "${IMAGE_TAG}" ]]; then + echo "Usage: $0 [platforms]" >&2 + exit 1 +fi + +if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then + docker buildx create --name "${BUILDER_NAME}" --use +else + docker buildx use "${BUILDER_NAME}" +fi + +docker buildx inspect --bootstrap >/dev/null + +echo "[buildx] building and pushing ${IMAGE_TAG}" +echo "[buildx] platforms: ${PLATFORMS}" + +docker buildx build \ + --platform "${PLATFORMS}" \ + -t "${IMAGE_TAG}" \ + --push \ + . + +echo "[buildx] done: ${IMAGE_TAG}"