From edc00444dca824d0b434401f7e534590a985db3e Mon Sep 17 00:00:00 2001 From: liumangmang Date: Tue, 30 Jun 2026 17:25:18 +0800 Subject: [PATCH] fix(nox-api): address duplicate creation risks, pending upgrading issues, cache warm error handling, test speedups, and frontend display support for pending keys --- backend/app/routers/upstreams.py | 42 +++--- backend/app/services/upstream_client.py | 6 +- backend/test_upstream_key_sync.py | 165 ++++++++++++++++++++++++ frontend/src/views/Upstreams.vue | 5 +- 4 files changed, 196 insertions(+), 22 deletions(-) diff --git a/backend/app/routers/upstreams.py b/backend/app/routers/upstreams.py index fc87925..71c9696 100644 --- a/backend/app/routers/upstreams.py +++ b/backend/app/routers/upstreams.py @@ -426,8 +426,10 @@ def _ensure_group_key( existing = client.find_smartup_group_key(gid, stable_name, prefix) if existing is not None: found_by_name = stable_name - except Exception: - existing = None + except Exception as exc: + # 远端查询失败,不要假定不存在,直接抛出异常,让外层捕获并返回 failed + raise UpstreamError(f"查询远端 Key 失败: {exc}") from exc + if existing: key_id = str(existing.get("id") or "") key_value = _extract_plaintext_key(existing) @@ -443,9 +445,18 @@ def _ensure_group_key( if found_by_name != lookup_name: row.key_name = found_by_name row.raw_json = json.dumps(existing, ensure_ascii=False) - # pending 行回填成功后升级为 exists - row.status = "exists" - row.error = None + # pending 行仅在成功回填明文 key 后升级为 exists + if row.status == "created_pending_key": + if key_value: + row.status = "exists" + row.error = None + else: + # 保持 pending,不升级 + row.status = "created_pending_key" + row.error = "远端找到 Token,但未能获取到明文 Key,待下次回填" + else: + row.status = "exists" + row.error = None row.updated_at = datetime.now(timezone.utc) db.add(row) db.commit() @@ -471,8 +482,8 @@ def _ensure_group_key( for candidate_name in names_to_search: try: existing = client.find_smartup_group_key(gid, candidate_name, prefix) - except Exception: - existing = None + except Exception as exc: + raise UpstreamError(f"查询远端 Key 失败: {exc}") from exc if existing: found_name = candidate_name break @@ -620,18 +631,13 @@ def generate_keys_by_groups( try: client.login() groups = client.get_available_groups(u.groups_endpoint) - except Exception as exc: - raise HTTPException(502, str(exc)) - - # New-API/Nox 上游:批量前预热 token 列表缓存(一次 /api/token/ 拉取), - # 避免每个分组都调 /api/token/search 触发 Nox 搜索限流(10 次/60 秒)。 - if _is_new_api_user_upstream(u) or _is_nox_api_upstream(u): - try: + # New-API/Nox 上游:批量前预热 token 列表缓存(一次 /api/token/ 拉取), + # 避免每个分组都调 /api/token/search 触发 Nox 搜索限流(10 次/60 秒)。 + # 如果预热缓存失败(例如 429 或连接错误),直接抛出异常以 abort 批量操作,避免无缓存的重复创建 + if (_is_new_api_user_upstream(u) or _is_nox_api_upstream(u)) and hasattr(client, "warm_token_list_cache"): client.warm_token_list_cache() - except Exception as exc: - logger.warning( - "generate_keys_by_groups: warm_token_list_cache failed for %s: %s", uid, exc - ) + except Exception as exc: + raise HTTPException(502, f"上游连接或缓存预热失败: {exc}") for group in groups: gid = _group_id(group) diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py index 0c63e9a..f2870f6 100644 --- a/backend/app/services/upstream_client.py +++ b/backend/app/services/upstream_client.py @@ -380,12 +380,14 @@ _RATE_LIMIT_BACKOFFS = (2, 5, 10, 20) _RATE_LIMIT_MAX_WAIT = 60 -def _retry_on_429(fn: Callable[[], Any], backoffs: tuple[int, ...] = _RATE_LIMIT_BACKOFFS) -> Any: +def _retry_on_429(fn: Callable[[], Any], backoffs: tuple[float, ...] | None = None) -> Any: """执行 fn(),若遇 429 则按退避序列重试,超限后抛 UpstreamError。 fn 应是 HTTP 调用;429 响应会被 httpx.HTTPStatusError 捕获(raise_for_status 之后) 或者 fn 内部已经解析为 UpstreamError 且 message 含 429。 """ + if backoffs is None: + backoffs = _RATE_LIMIT_BACKOFFS total_waited = 0 for attempt, wait in enumerate(backoffs): try: @@ -783,7 +785,7 @@ class UpstreamClient: 缓存以 token name 为 key;同名取最新(id 最大)。 """ - all_tokens = self._list_all_new_api_tokens() + all_tokens = _retry_on_429(lambda: self._list_all_new_api_tokens()) cache: dict[str, dict[str, Any]] = {} for t in all_tokens: name = str(t.get("name") or t.get("key_name") or "") diff --git a/backend/test_upstream_key_sync.py b/backend/test_upstream_key_sync.py index 57cefb7..1292b3b 100644 --- a/backend/test_upstream_key_sync.py +++ b/backend/test_upstream_key_sync.py @@ -1570,3 +1570,168 @@ def test_ensure_group_key_nox_pending_re_invoke_backfills(db_session): assert row.status == "exists" assert row.key_id == "remote-backfill-id" assert row.key_value == "sk-backfilled-plain-key" + + +def test_ensure_group_key_remote_query_failure_aborts(db_session): + """验证当远端查询抛出异常时,直接中断,不重建或重置本地记录。""" + from app.routers.upstreams import _ensure_group_key + from app.schemas.upstream import GenerateKeysByGroupsRequest + from app.models.upstream_key import UpstreamGeneratedKey + from app.services.upstream_client import UpstreamError + + upstream = Upstream( + name="NoxAPI", + base_url="http://nox.local", + api_prefix="", + auth_type="nox_token", + auth_config_json=json.dumps({"token": "abc", "new_api_user": "7"}), + groups_endpoint="/api/user/self/groups", + rate_endpoint="/api/user/self/groups", + ) + db_session.add(upstream) + db_session.commit() + db_session.refresh(upstream) + + # 1. 本地有记录的情形 + old_key_name = f"SmartUp-{upstream.id}-VIP-vip" + db_session.add(UpstreamGeneratedKey( + upstream_id=upstream.id, + group_id="vip", + group_name="VIP", + key_name=old_key_name, + key_value="sk-local", + managed_prefix="SmartUp", + key_id="123", + status="exists", + )) + db_session.commit() + + class MockClient: + def find_smartup_group_key(self, *args, **kwargs): + raise UpstreamError("429 Too Many Requests") + + def create_api_key(self, *args, **kwargs): + raise AssertionError("create_api_key should not be called on query failure") + + group = {"id": "vip", "name": "VIP"} + body = GenerateKeysByGroupsRequest(group_ids=["vip"], name_prefix="SmartUp", quota=0) + + result = _ensure_group_key(db_session, MockClient(), upstream, group, "SmartUp", body) + + assert result.status == "failed" + assert "429 Too Many Requests" in (result.error or "") + + # 本地状态应该保持 exists, 不应该被置为 replaced + row = db_session.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.upstream_id == upstream.id, + UpstreamGeneratedKey.group_id == "vip", + ).one() + assert row.status == "exists" + assert row.key_value == "sk-local" + + +def test_ensure_group_key_pending_re_invoke_no_key_stays_pending(db_session): + """当重试 pending 记录时,如果远端找到 token 但无法获取到明文,应保持 created_pending_key 状态。""" + from app.routers.upstreams import _ensure_group_key + from app.schemas.upstream import GenerateKeysByGroupsRequest + from app.models.upstream_key import UpstreamGeneratedKey + + upstream = Upstream( + name="NoxAPI", + base_url="http://nox.local", + api_prefix="", + auth_type="nox_token", + auth_config_json=json.dumps({"token": "abc", "new_api_user": "7"}), + groups_endpoint="/api/user/self/groups", + rate_endpoint="/api/user/self/groups", + ) + db_session.add(upstream) + db_session.commit() + db_session.refresh(upstream) + + old_key_name = f"SmartUp-{upstream.id}-PendingGrp-pending-grp" + db_session.add(UpstreamGeneratedKey( + upstream_id=upstream.id, + group_id="pending-grp", + group_name="PendingGrp", + key_name=old_key_name, + key_value="", + masked_key="", + managed_prefix="SmartUp", + key_id=None, + status="created_pending_key", + )) + db_session.commit() + + class MockClient: + def find_smartup_group_key(self, group_id, name, prefix): + # 查到了 token,但无明文 key (例如只返回了 masked_key) + if name == old_key_name: + return {"id": "remote-backfill-id", "name": old_key_name, "masked_key": "sk-****"} + return None + + def create_api_key(self, *args, **kwargs): + raise AssertionError("create_api_key should not be called") + + group = {"id": "pending-grp", "name": "PendingGrp"} + body = GenerateKeysByGroupsRequest(group_ids=["pending-grp"], name_prefix="SmartUp", quota=0) + + result = _ensure_group_key(db_session, MockClient(), upstream, group, "SmartUp", body) + + assert result.status == "created_pending_key" + assert "未能获取到明文 Key" in (result.error or "") + + # 本地状态应该保持 pending + row = db_session.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.upstream_id == upstream.id, + UpstreamGeneratedKey.group_id == "pending-grp", + ).one() + assert row.status == "created_pending_key" + assert row.key_id == "remote-backfill-id" + assert row.key_value == "" + + +def test_generate_keys_by_groups_warm_cache_failure_fails_fast(db_session, monkeypatch): + """预热缓存失败时,generate_keys_by_groups 应该直接 abort 抛出 502。""" + from app.routers.upstreams import generate_keys_by_groups + from app.schemas.upstream import GenerateKeysByGroupsRequest + from fastapi import HTTPException + + upstream = Upstream( + name="NoxAPI", + base_url="http://nox.local", + api_prefix="", + auth_type="nox_token", + auth_config_json=json.dumps({"token": "abc", "new_api_user": "7"}), + groups_endpoint="/api/user/self/groups", + rate_endpoint="/api/user/self/groups", + ) + db_session.add(upstream) + db_session.commit() + db_session.refresh(upstream) + + class MockClient: + def __init__(self, *args, **kwargs): + pass + def __enter__(self): + return self + def __exit__(self, *args): + pass + def login(self): + pass + def get_available_groups(self, endpoint): + return [{"id": "vip", "name": "VIP"}] + def warm_token_list_cache(self): + raise UpstreamError("429 Too Many Requests") + + from app.routers import upstreams + monkeypatch.setattr(upstreams, "UpstreamClient", MockClient) + + body = GenerateKeysByGroupsRequest(group_ids=["vip"], name_prefix="SmartUp", quota=0) + + with pytest.raises(HTTPException) as excinfo: + generate_keys_by_groups(upstream.id, body, db=db_session) + + assert excinfo.value.status_code == 502 + assert "上游连接或缓存预热失败" in excinfo.value.detail + diff --git a/frontend/src/views/Upstreams.vue b/frontend/src/views/Upstreams.vue index 65f8a57..75a0c7d 100644 --- a/frontend/src/views/Upstreams.vue +++ b/frontend/src/views/Upstreams.vue @@ -322,7 +322,7 @@ @@ -435,6 +435,7 @@ @@ -957,7 +958,7 @@ function shrinkError(value: string) { return value.length > 40 ? `${value.slice(0, 40)}…` : value } -const keyStatusLabel = (s: string) => ({ created: '已创建', imported: '已导入', import_failed: '导入失败', failed: '失败' }[s] || s) +const keyStatusLabel = (s: string) => ({ created: '已创建', imported: '已导入', import_failed: '导入失败', failed: '失败', created_pending_key: '待回填' }[s] || s) async function loadList() { tableLoading.value = true