Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,内置管理台、账号池、工具调用解析、图片生成链路与多种部署方式。

---

Expand Down Expand Up @@ -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:<tag>
```

服务器端仍然只需要 `docker compose pull && docker compose up -d`。

### 关于 docker-compose 文件

生产部署(Linux/Windows)建议使用拉镜像模式:

- `docker-compose.yml`:默认 `image: yujunzhixue/qwen2api:latest`(或通过 `QWEN2API_IMAGE` 覆盖)
- `docker-compose.build.yml`:仅用于本地从源码构建(开发/调试)

---

### 方式二:本地源码运行

此方式适用于本地开发与调试。
Expand Down
21 changes: 21 additions & 0 deletions backend/core/account_pool/pool_acquire.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
"""
立即获取账号(不等待)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"):
"""
Expand Down
13 changes: 12 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"):
Expand Down Expand Up @@ -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 将不可用。")

Expand Down
6 changes: 6 additions & 0 deletions docker-compose.build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
services:
qwen2api:
image: ${QWEN2API_IMAGE:-qwen2api:local-fix}
build:
context: .
dockerfile: Dockerfile
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
qwen2api:
image: yujunzhixue/qwen2api:latest
image: ${QWEN2API_IMAGE:-yujunzhixue/qwen2api:latest}
container_name: qwen2api
restart: unless-stopped
init: true
Expand Down
30 changes: 25 additions & 5 deletions frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountItem[]>([])
const [email, setEmail] = useState("")
Expand All @@ -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 = () => {
Expand Down
36 changes: 36 additions & 0 deletions scripts/buildx-push.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail

# Usage:
# scripts/buildx-push.sh <image[:tag]> [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 <image[:tag]> [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}"
Loading