From a8ab1382cd9229b51c8ff3aea71e7f40f56dbd25 Mon Sep 17 00:00:00 2001 From: jk <2081217232@qq.com> Date: Sun, 4 Oct 2026 09:41:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(oauth):=20=E5=8F=8C=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=85=A5=E6=B1=A0=EF=BC=88intl/cn=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94bigmodel.cn=20=E5=9B=BD=E5=86=85=E7=89=88?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E4=B8=8E=E4=B8=9A=E5=8A=A1=E5=9F=9F=E5=88=86?= =?UTF-8?q?=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth flow 按 realm 分域:intl=z.ai(data.zai 会话凭证需 z/login 换业务 token)、 cn=bigmodel.cn(data.bigmodel 即业务 token,免二次换取);init provider 随区域 - Account 增加 realm 字段并随导出/导入持久化;bigmodel 通道 JWT 凭证自动归位 zai/cn - 回退 API Key 业务域随区域(cn → open.bigmodel.cn),Plan 通道两端点同域不分 - 后台新增/授权登录支持区域选择;前端账号池展示区域与国内版授权引导 - Mock 上游支持 oauth_poll_realm 场景;新增 realm 单测与 cn 全流程集成测试 --- app/agent.py | 8 +- app/constants.py | 17 ++++ app/models.py | 11 ++- app/oauth.py | 83 +++++++++++++---- app/routes/admin_api.py | 40 +++++--- app/settings.py | 3 + app/store.py | 10 +- frontend/admin/accounts.html | 55 +++++++++-- tests/conftest.py | 2 + tests/integration/test_oauth_login.py | 129 ++++++++++++++++++++++++++ tests/mock_upstream/server.py | 14 ++- tests/unit/test_constants.py | 16 ++++ tests/unit/test_models_realm.py | 65 +++++++++++++ 13 files changed, 405 insertions(+), 48 deletions(-) create mode 100644 tests/unit/test_models_realm.py diff --git a/app/agent.py b/app/agent.py index f189963..d36e0a5 100644 --- a/app/agent.py +++ b/app/agent.py @@ -75,10 +75,16 @@ def build_request( if provider == "zai": if account.uses_plan_channel() and not force_fallback: + # Plan 通道 intl/cn 同端点(zcode.z.ai,JWT 决定归属) target_url = settings.UPSTREAM["zai"] auth = {"Authorization": f"Bearer {account.jwt_token}"} elif account.api_key: - target_url = settings.UPSTREAM["zai_fallback"] + # 回退 Key 的域随账号区域:intl → api.z.ai;cn(bigmodel OAuth + # 兑换的 Key)→ open.bigmodel.cn + if account.realm == "cn": + target_url = settings.UPSTREAM["bigmodel"] + else: + target_url = settings.UPSTREAM["zai_fallback"] auth = {"x-api-key": account.api_key} else: raise RuntimeError("账号缺少有效凭证") diff --git a/app/constants.py b/app/constants.py index 53104f5..cf8e8ea 100644 --- a/app/constants.py +++ b/app/constants.py @@ -41,6 +41,23 @@ OAUTH_CLI_INIT_PATH = "/api/v1/oauth/cli/init" OAUTH_CLI_POLL_PATH = "/api/v1/oauth/cli/poll" # + /{flow_id} +# OAuth 双区域(对齐 codeapi providers/zcode/realms.py,官方客户端 3.14.x 实证): +# CLI init/poll 端点 intl/cn 共用,provider 参数与业务域随区域—— +# intl → provider=zai,授权页 chat.z.ai;poll 的 data.zai.access_token 是 +# OAuth 会话凭证,需经 api.z.ai/api/auth/z/login 换业务 token; +# cn → provider=bigmodel,授权页 bigmodel.cn/login(服务端回调); +# poll 的 data.bigmodel.access_token 本身就是业务 token(官方客户端 +# BigModel adapter 不做二次换取)。 +# 两个区域的 data.token 都是 zcodejwttoken(Plan 通道 Bearer,JWT 决定归属, +# messages / billing / claim 端点 intl/cn 完全相同,无需分域)。 +BIGMODEL_BUSINESS_ORIGIN = "https://bigmodel.cn" +OAUTH_REALMS = { + "intl": {"oauth_provider": "zai", "business_origin": ZAI_API_ORIGIN}, + "cn": {"oauth_provider": "bigmodel", "business_origin": BIGMODEL_BUSINESS_ORIGIN}, +} +# poll 响应里业务 token 块的块名随区域 +OAUTH_PROVIDER_BLOCKS = {"intl": "zai", "cn": "bigmodel"} + # ── 客户端版本(单一真相源:官方客户端 3.14.4,2026-10-02 跟进)──────────────── # 客户端 claim 头实测缺版本/平台头 → 上游 3007;client/configs 带 platform 参数 → 3001 # 出处:本机官方 ZCode.exe(注册表 DisplayVersion=3.14.4,2026-10-02 实测; diff --git a/app/models.py b/app/models.py index 36b10c0..5f32793 100644 --- a/app/models.py +++ b/app/models.py @@ -7,6 +7,9 @@ from dataclasses import asdict, dataclass, field PROVIDERS = ("zai", "bigmodel") +# 账号区域:intl=国际版(z.ai 授权,业务域 api.z.ai);cn=国内版(bigmodel.cn 授权, +# 业务域 bigmodel.cn)。Plan 通道(messages/billing/claim)两区域同端点,JWT 决定归属 +REALMS = ("intl", "cn") class Status: @@ -33,6 +36,7 @@ class Account: name: str provider: str mode: str # "jwt" | "apiKey" + realm: str = "intl" # 账号区域(models.REALMS):intl=国际版 / cn=国内版 jwt_token: str | None = None api_key: str | None = None enabled: bool = True @@ -61,14 +65,18 @@ class Account: installed_at: float | None = None # 按账号安装序完成时间;None = 未安装 @staticmethod - def create(provider: str, name: str, secret: str) -> Account: + def create(provider: str, name: str, secret: str, realm: str = "") -> Account: secret = (secret or "").strip() is_jwt = secret.count(".") == 2 and provider == "zai" + # 区域缺省/非法值时自动归区:bigmodel 提供商即国内域 Key,其余国际版 + if realm not in REALMS: + realm = "cn" if provider == "bigmodel" else "intl" return Account( id=_account_id(name), name=name or f"{provider}-account", provider=provider, mode="jwt" if is_jwt else "apiKey", + realm=realm, jwt_token=secret if is_jwt else None, api_key=None if is_jwt else secret, ) @@ -192,6 +200,7 @@ def public_view(self) -> dict: "id": self.id, "name": self.name, "provider": self.provider, + "realm": self.realm, "mode": self.mode, "token_masked": masked, "enabled": self.enabled, diff --git a/app/oauth.py b/app/oauth.py index eab588b..b09223a 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -1,6 +1,16 @@ -"""Z.AI OAuth 登录流程。 - -主要供 CLI `login zai` 使用:发起 OAuth → 轮询 → 兑换 API Key。 +"""Z.AI / BigModel(国内版)OAuth 登录流程。 + +主要供 CLI `login zai` 与后台「授权登录」使用:发起 OAuth → 轮询 → 兑换 API Key。 + +双区域(constants.OAUTH_REALMS,对齐官方客户端 3.14.x 实证): +- intl(zai):init `{"provider":"zai"}`,授权页 chat.z.ai;poll 的 + `data.zai.access_token` 是 OAuth 会话凭证,需经 `api.z.ai/api/auth/z/login` + 换业务 token 后再走机构/项目/Key 兑换链(业务域 api.z.ai); +- cn(bigmodel):init `{"provider":"bigmodel"}`,授权页 bigmodel.cn/login + (服务端回调);poll 的 `data.bigmodel.access_token` 本身就是业务 token + (官方客户端 BigModel adapter 不做二次换取),业务链在 bigmodel.cn 域。 +两个区域的 `data.token` 都是 zcodejwttoken(Plan 通道 Bearer),messages / +billing / claim 端点 intl/cn 完全相同,无需分域。 """ from __future__ import annotations @@ -9,11 +19,16 @@ import httpx -from . import settings +from . import constants, settings + +REALM_INTL = "intl" +REALM_CN = "cn" class ZaiAuthFlow: - """api_base / exchange_origin 可注入(测试指向 Mock 上游); + """一次 OAuth CLI 登录会话;realm 决定 provider 参数、业务 token 块与业务域。 + + api_base / exchange_origin 可注入(测试指向 Mock 上游); 默认值来自 settings(其缺省又来自 constants 收口)。 官方 CLI 规范(对齐 zcode.cjs createZaiCliOAuthClient): @@ -26,9 +41,19 @@ class ZaiAuthFlow: (保留旧协议兼容)。 """ - def __init__(self, api_base: str | None = None, exchange_origin: str | None = None) -> None: + def __init__(self, api_base: str | None = None, exchange_origin: str | None = None, + realm: str = REALM_INTL) -> None: + realm = str(realm or REALM_INTL).strip().lower() + self.realm = realm if realm in constants.OAUTH_REALMS else REALM_INTL + self.oauth_provider = constants.OAUTH_REALMS[self.realm]["oauth_provider"] self.api_base = api_base or settings.OAUTH_API_BASE - self.exchange_origin = exchange_origin or settings.ZAI_EXCHANGE_ORIGIN + if exchange_origin: + self.exchange_origin = exchange_origin + else: + self.exchange_origin = ( + settings.BIGMODEL_EXCHANGE_ORIGIN if self.realm == REALM_CN + else settings.ZAI_EXCHANGE_ORIGIN + ) self.poll_token = secrets.token_hex(32) async def init(self) -> tuple[str, str]: @@ -39,7 +64,7 @@ async def init(self) -> tuple[str, str]: "Authorization": f"Bearer {self.poll_token}", "Content-Type": "application/json", }, - json={"provider": "zai"}, + json={"provider": self.oauth_provider}, ) res.raise_for_status() body = res.json() @@ -64,19 +89,39 @@ async def poll(self, flow_id: str) -> dict: res.raise_for_status() return res.json().get("data") or {} + def business_access_token(self, data: dict) -> str: + """从 poll 结果提取业务 access_token;块名随区域(intl=data.zai,cn=data.bigmodel)。""" + block = data.get(constants.OAUTH_PROVIDER_BLOCKS[self.realm]) + if isinstance(block, dict): + return str(block.get("access_token") or "").strip() + return "" + + async def _resolve_business_token(self, client: httpx.AsyncClient, access_token: str) -> str: + """OAuth access_token → 业务 token。 + + cn(bigmodel):poll 下发的 access_token 本身就是业务 token,直接使用; + intl(zai):需经 exchange_origin 的 /api/auth/z/login 换业务 token。 + """ + if self.realm == REALM_CN: + return access_token + login = await client.post( + f"{self.exchange_origin}/api/auth/z/login", + headers={"Content-Type": "application/json"}, + json={"token": access_token}, + ) + login.raise_for_status() + biz = (login.json().get("data") or {}) + biz_token = biz.get("access_token") or biz.get("accessToken") + if not biz_token: + raise RuntimeError("返回数据中不含业务凭证") + return biz_token + async def exchange_api_key(self, access_token: str) -> str: - """OAuth access_token → 业务 token → 机构/项目 → API Key。""" + """OAuth access_token → 业务 token → 机构/项目 → API Key(业务域随区域)。""" + if not access_token: + raise RuntimeError("缺少 access_token,无法兑换 API Key") async with httpx.AsyncClient(timeout=30) as client: - login = await client.post( - f"{self.exchange_origin}/api/auth/z/login", - headers={"Content-Type": "application/json"}, - json={"token": access_token}, - ) - login.raise_for_status() - biz = (login.json().get("data") or {}) - biz_token = biz.get("access_token") or biz.get("accessToken") - if not biz_token: - raise RuntimeError("返回数据中不含业务凭证") + biz_token = await self._resolve_business_token(client, access_token) info = await client.get( f"{self.exchange_origin}/api/biz/customer/getCustomerInfo", diff --git a/app/routes/admin_api.py b/app/routes/admin_api.py index 0299316..316222c 100644 --- a/app/routes/admin_api.py +++ b/app/routes/admin_api.py @@ -21,8 +21,8 @@ report_activation_events, ) from ..claim import claim as do_claim -from ..models import PROVIDERS, Status -from ..oauth import ZaiAuthFlow +from ..models import PROVIDERS, REALMS, Status +from ..oauth import REALM_INTL, ZaiAuthFlow from ..quota import fetch_quota, refresh_accounts from ..store import store @@ -68,8 +68,11 @@ async def status_info(): @router.post("/accounts") async def add_accounts(payload: dict = Body(...)): provider = payload.get("provider", "zai") + realm = str(payload.get("realm") or "").strip().lower() if provider not in PROVIDERS: raise HTTPException(400, "不支持的 provider") + if realm and realm not in REALMS: + raise HTTPException(400, "realm 必须是 intl 或 cn") tokens = payload.get("tokens") or [] if isinstance(tokens, str): tokens = [t.strip() for t in tokens.splitlines() if t.strip()] @@ -77,11 +80,18 @@ async def add_accounts(payload: dict = Body(...)): if not tokens: raise HTTPException(400, "请输入至少一个 Token / API Key") + # 纠偏:bigmodel 提供商通道只接受 open.bigmodel.cn 的 API Key(x-api-key); + # 两段点的 zcodejwttoken 在该通道永远不可用,自动归位为 zai + 国内版 + migrated = [t for t in tokens if provider == "bigmodel" and t.count(".") == 2] + if migrated: + provider, realm = "zai", "cn" + logs.info("accounts", f"检测到 {len(migrated)} 个 JWT 凭证,已从 bigmodel 归位为 zai/cn") + added = [] existing = {a.id for a in store.list_accounts(provider)} # 识别真新增(重复 token 跳过) for tok in dict.fromkeys(tokens): # 去重保序 name = payload.get("name") or f"{provider}-{len(store.list_accounts(provider)) + 1}" - acc = store.add_account(provider, name, tok) + acc = store.add_account(provider, name, tok, realm=realm) added.append(acc.id) # 立即刷新一次额度(仅 zai jwt) fresh = [a for a in store.list_accounts(provider) if a.id in added and a.mode == "jwt"] @@ -225,24 +235,30 @@ def _login_gc() -> None: @router.post("/login/start") async def login_start(payload: dict = Body(default=None)): - """发起 Z.AI OAuth,返回授权链接供前端展示。 + """发起 OAuth 授权,返回授权链接供前端展示。 - payload 可选 {"label": "acct-1"} —— 作为账号名前缀入池,便于多号识别。 + payload 可选 {"label": "acct-1", "realm": "intl"|"cn"} —— label 作为账号名 + 前缀入池,便于多号识别;realm 决定授权页与业务域(intl=z.ai 国际版, + cn=bigmodel.cn 国内版),缺省 intl。 """ payload = payload or {} label = (payload.get("label") or "").strip()[:32] + realm = str(payload.get("realm") or REALM_INTL).strip().lower() + if realm not in REALMS: + raise HTTPException(400, "realm 必须是 intl 或 cn") _login_gc() - flow = ZaiAuthFlow() + flow = ZaiAuthFlow(realm=realm) try: flow_id, authorize_url = await flow.init() except Exception as err: # noqa: BLE001 - logs.warn("oauth", f"登录初始化失败: {type(err).__name__}: {err}") + logs.warn("oauth", f"登录初始化失败 realm={realm}: {type(err).__name__}: {err}") raise HTTPException(502, f"登录初始化失败: {err}") from err _login_flows[flow_id] = {"flow": flow, "created": time.time(), "label": label} - logs.info("oauth", f"发起登录 flow_id={flow_id} label={label or '-'}") + logs.info("oauth", f"发起登录 realm={realm} flow_id={flow_id} label={label or '-'}") return { "flow_id": flow_id, "authorize_url": authorize_url, + "realm": realm, "expires_in": int(LOGIN_FLOW_TTL), } @@ -297,17 +313,19 @@ async def login_poll(flow_id: str): # JWT 先入池并立刻 ready;兑换 API Key / 额度刷新改后台,避免卡住前端下一轮 poll。 zcode_jwt = data.get("token") - access_token = (data.get("zai") or {}).get("access_token") + # 业务 token 块随区域:intl=data.zai,cn=data.bigmodel(flow 已带 realm) + access_token = flow.business_access_token(data) + realm = getattr(flow, "realm", REALM_INTL) label = entry.get("label") or "oauth-login" account = None if zcode_jwt: - account = store.add_account("zai", label, zcode_jwt) + account = store.add_account("zai", label, zcode_jwt, realm=realm) elif access_token: try: api_key = await asyncio.wait_for( flow.exchange_api_key(access_token), timeout=LOGIN_EXCHANGE_TIMEOUT ) - account = store.add_account("zai", label, api_key) + account = store.add_account("zai", label, api_key, realm=realm) except Exception as err: # noqa: BLE001 logs.warn("oauth", f"无 JWT 时兑换 API Key 失败: {err}") diff --git a/app/settings.py b/app/settings.py index 67f0ab5..c5bcd10 100644 --- a/app/settings.py +++ b/app/settings.py @@ -138,6 +138,9 @@ def _bool(env_name: str, default: bool) -> bool: # OAuth 与兑换链 origin(测试时指向 Mock 上游) OAUTH_API_BASE = os.getenv("ZCODE_OAUTH_API_BASE", constants.ZCODE_ORIGIN + "/api/v1") ZAI_EXCHANGE_ORIGIN = os.getenv("ZCODE_EXCHANGE_ORIGIN", constants.ZAI_API_ORIGIN) +BIGMODEL_EXCHANGE_ORIGIN = os.getenv( + "ZCODE_BIGMODEL_EXCHANGE_ORIGIN", constants.BIGMODEL_BUSINESS_ORIGIN +) USER_AGENT = os.getenv("UPSTREAM_USER_AGENT", constants.USER_AGENT) APP_VERSION = "2.6.8" diff --git a/app/store.py b/app/store.py index d9d6f74..8a379a7 100644 --- a/app/store.py +++ b/app/store.py @@ -205,10 +205,10 @@ def _find_locked(self, provider: str, id_or_name: str) -> Account | None: return None # ── 账号增删改 ─────────────────────────────────────────────────────────── - def add_account(self, provider: str, name: str, secret: str) -> Account: + def add_account(self, provider: str, name: str, secret: str, realm: str = "") -> Account: if provider not in PROVIDERS: raise ValueError(f"不支持的 provider: {provider}") - account = Account.create(provider, name, secret) + account = Account.create(provider, name, secret, realm=realm) with self._lock: for a in self._accounts[provider]: if a.secret and a.secret == account.secret: @@ -294,7 +294,8 @@ def export(self) -> dict: "exported_at": time.time(), "providers": { p: [ - {"name": a.name, "mode": a.mode, "secret": a.secret} + {"name": a.name, "mode": a.mode, "secret": a.secret, + "realm": a.realm} for a in self._accounts[p] ] for p in PROVIDERS @@ -311,7 +312,8 @@ def import_accounts(self, payload: dict) -> int: secret = it.get("secret") or it.get("token") or it.get("jwtToken") or it.get("apiKey") if not secret: continue - self.add_account(provider, it.get("name", provider), secret) + self.add_account(provider, it.get("name", provider), secret, + realm=str(it.get("realm") or "")) count += 1 return count diff --git a/frontend/admin/accounts.html b/frontend/admin/accounts.html index 3fd356b..70e3ffa 100644 --- a/frontend/admin/accounts.html +++ b/frontend/admin/accounts.html @@ -77,7 +77,11 @@