fix(nox-api): address duplicate creation risks, pending upgrading issues, cache warm error handling, test speedups, and frontend display support for pending keys
This commit is contained in:
@@ -426,8 +426,10 @@ def _ensure_group_key(
|
|||||||
existing = client.find_smartup_group_key(gid, stable_name, prefix)
|
existing = client.find_smartup_group_key(gid, stable_name, prefix)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
found_by_name = stable_name
|
found_by_name = stable_name
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
existing = None
|
# 远端查询失败,不要假定不存在,直接抛出异常,让外层捕获并返回 failed
|
||||||
|
raise UpstreamError(f"查询远端 Key 失败: {exc}") from exc
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
key_id = str(existing.get("id") or "")
|
key_id = str(existing.get("id") or "")
|
||||||
key_value = _extract_plaintext_key(existing)
|
key_value = _extract_plaintext_key(existing)
|
||||||
@@ -443,7 +445,16 @@ def _ensure_group_key(
|
|||||||
if found_by_name != lookup_name:
|
if found_by_name != lookup_name:
|
||||||
row.key_name = found_by_name
|
row.key_name = found_by_name
|
||||||
row.raw_json = json.dumps(existing, ensure_ascii=False)
|
row.raw_json = json.dumps(existing, ensure_ascii=False)
|
||||||
# pending 行回填成功后升级为 exists
|
# 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.status = "exists"
|
||||||
row.error = None
|
row.error = None
|
||||||
row.updated_at = datetime.now(timezone.utc)
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
@@ -471,8 +482,8 @@ def _ensure_group_key(
|
|||||||
for candidate_name in names_to_search:
|
for candidate_name in names_to_search:
|
||||||
try:
|
try:
|
||||||
existing = client.find_smartup_group_key(gid, candidate_name, prefix)
|
existing = client.find_smartup_group_key(gid, candidate_name, prefix)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
existing = None
|
raise UpstreamError(f"查询远端 Key 失败: {exc}") from exc
|
||||||
if existing:
|
if existing:
|
||||||
found_name = candidate_name
|
found_name = candidate_name
|
||||||
break
|
break
|
||||||
@@ -620,18 +631,13 @@ def generate_keys_by_groups(
|
|||||||
try:
|
try:
|
||||||
client.login()
|
client.login()
|
||||||
groups = client.get_available_groups(u.groups_endpoint)
|
groups = client.get_available_groups(u.groups_endpoint)
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(502, str(exc))
|
|
||||||
|
|
||||||
# New-API/Nox 上游:批量前预热 token 列表缓存(一次 /api/token/ 拉取),
|
# New-API/Nox 上游:批量前预热 token 列表缓存(一次 /api/token/ 拉取),
|
||||||
# 避免每个分组都调 /api/token/search 触发 Nox 搜索限流(10 次/60 秒)。
|
# 避免每个分组都调 /api/token/search 触发 Nox 搜索限流(10 次/60 秒)。
|
||||||
if _is_new_api_user_upstream(u) or _is_nox_api_upstream(u):
|
# 如果预热缓存失败(例如 429 或连接错误),直接抛出异常以 abort 批量操作,避免无缓存的重复创建
|
||||||
try:
|
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()
|
client.warm_token_list_cache()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
raise HTTPException(502, f"上游连接或缓存预热失败: {exc}")
|
||||||
"generate_keys_by_groups: warm_token_list_cache failed for %s: %s", uid, exc
|
|
||||||
)
|
|
||||||
|
|
||||||
for group in groups:
|
for group in groups:
|
||||||
gid = _group_id(group)
|
gid = _group_id(group)
|
||||||
|
|||||||
@@ -380,12 +380,14 @@ _RATE_LIMIT_BACKOFFS = (2, 5, 10, 20)
|
|||||||
_RATE_LIMIT_MAX_WAIT = 60
|
_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(),若遇 429 则按退避序列重试,超限后抛 UpstreamError。
|
||||||
|
|
||||||
fn 应是 HTTP 调用;429 响应会被 httpx.HTTPStatusError 捕获(raise_for_status 之后)
|
fn 应是 HTTP 调用;429 响应会被 httpx.HTTPStatusError 捕获(raise_for_status 之后)
|
||||||
或者 fn 内部已经解析为 UpstreamError 且 message 含 429。
|
或者 fn 内部已经解析为 UpstreamError 且 message 含 429。
|
||||||
"""
|
"""
|
||||||
|
if backoffs is None:
|
||||||
|
backoffs = _RATE_LIMIT_BACKOFFS
|
||||||
total_waited = 0
|
total_waited = 0
|
||||||
for attempt, wait in enumerate(backoffs):
|
for attempt, wait in enumerate(backoffs):
|
||||||
try:
|
try:
|
||||||
@@ -783,7 +785,7 @@ class UpstreamClient:
|
|||||||
|
|
||||||
缓存以 token name 为 key;同名取最新(id 最大)。
|
缓存以 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]] = {}
|
cache: dict[str, dict[str, Any]] = {}
|
||||||
for t in all_tokens:
|
for t in all_tokens:
|
||||||
name = str(t.get("name") or t.get("key_name") or "")
|
name = str(t.get("name") or t.get("key_name") or "")
|
||||||
|
|||||||
@@ -1570,3 +1570,168 @@ def test_ensure_group_key_nox_pending_re_invoke_backfills(db_session):
|
|||||||
assert row.status == "exists"
|
assert row.status == "exists"
|
||||||
assert row.key_id == "remote-backfill-id"
|
assert row.key_id == "remote-backfill-id"
|
||||||
assert row.key_value == "sk-backfilled-plain-key"
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="96">
|
<el-table-column label="状态" width="96">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" :type="row.status === 'import_failed' || row.status === 'failed' ? 'danger' : row.status === 'imported' ? 'success' : 'info'">
|
<el-tag size="small" :type="row.status === 'import_failed' || row.status === 'failed' ? 'danger' : row.status === 'imported' ? 'success' : row.status === 'created_pending_key' ? 'warning' : 'info'">
|
||||||
{{ keyStatusLabel(row.status) }}
|
{{ keyStatusLabel(row.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -435,6 +435,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.status === 'created'" size="small" type="success">新创建</el-tag>
|
<el-tag v-if="row.status === 'created'" size="small" type="success">新创建</el-tag>
|
||||||
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
|
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 'created_pending_key'" size="small" type="warning">待回填</el-tag>
|
||||||
<el-tag v-else size="small" type="danger">失败</el-tag>
|
<el-tag v-else size="small" type="danger">失败</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -957,7 +958,7 @@ function shrinkError(value: string) {
|
|||||||
return value.length > 40 ? `${value.slice(0, 40)}…` : value
|
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() {
|
async function loadList() {
|
||||||
tableLoading.value = true
|
tableLoading.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user