feat: Provider 异步删除、可配置密码策略、Hub 超时优化及多项改进

- 新增 Provider 异步删除任务系统,后台分阶段删除子资源并清理残留引用
- 新增可配置密码策略等级(weak/medium/strong),支持系统设置面板调整
- aether-hub 升级至 0.1.4,idle timeout 支持禁用(设为 0),worker 默认超时调整为 120s
- OAuth 手动续期增加 Redis 分布式锁,防止并发刷新冲突
- ProxyNode 心跳检测改为 asyncio.to_thread,避免阻塞事件循环
- 删除 ModelMultiSelect 和 useInvalidModels,MultiSelect 组件通用化
- 明确 allowed_providers/allowed_api_formats 的 NULL 与空数组语义
- 前端 StandaloneKeyFormDialog、UserFormDialog 等多处 UI 优化
- 新增 Alembic 迁移脚本清理 Provider 删除后的残留引用
- 补充相关测试用例
This commit is contained in:
fawney19
2026-03-12 01:11:35 +08:00
parent 0d770d1c4d
commit 6e51a3f45d
55 changed files with 3219 additions and 862 deletions

View File

@@ -9,8 +9,10 @@ from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from src.api.admin.api_keys.routes import (
AdminCreateStandaloneKeyAdapter,
AdminGetFullKeyAdapter,
AdminToggleApiKeyAdapter,
AdminUpdateApiKeyAdapter,
)
from src.api.admin.api_keys.routes import router as admin_api_keys_router
from src.api.admin.users.routes import (
@@ -20,6 +22,7 @@ from src.api.admin.users.routes import (
from src.api.admin.users.routes import router as admin_users_router
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.database import get_db
from src.models.api import CreateApiKeyRequest
def _build_context(db: MagicMock) -> SimpleNamespace:
@@ -296,3 +299,114 @@ def test_standalone_detail_route_does_not_expose_is_locked(monkeypatch: pytest.M
payload = response.json()
assert payload["id"] == "sa-key-2"
assert "is_locked" not in payload
@pytest.mark.asyncio
async def test_create_standalone_key_adapter_preserves_empty_restriction_lists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
captured: dict[str, object] = {}
created_key = SimpleNamespace(
id="sa-key-3",
name="Standalone Key 3",
get_display_key=lambda: "sk-stand...9012",
is_active=True,
rate_limit=None,
expires_at=None,
created_at=datetime.now(timezone.utc),
allowed_providers=[],
allowed_api_formats=[],
allowed_models=[],
)
def _create_api_key(**kwargs: object) -> tuple[SimpleNamespace, str]:
captured.update(kwargs)
return created_key, "sk-created"
monkeypatch.setattr(
"src.api.admin.api_keys.routes.ApiKeyService.create_api_key", _create_api_key
)
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.initialize_api_key_wallet",
lambda *_a, **_k: SimpleNamespace(id="wallet-1"),
)
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.serialize_wallet_summary",
lambda _wallet: {"id": "wallet-1"},
)
adapter = AdminCreateStandaloneKeyAdapter(
CreateApiKeyRequest(
name="Standalone Key 3",
initial_balance_usd=10,
allowed_providers=[],
allowed_api_formats=[],
allowed_models=[],
)
)
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id="admin-1"),
request=SimpleNamespace(state=SimpleNamespace()),
add_audit_metadata=lambda **_: None,
)
result = await adapter.handle(context)
assert result["id"] == "sa-key-3"
assert captured["allowed_providers"] == []
assert captured["allowed_api_formats"] == []
assert captured["allowed_models"] == []
@pytest.mark.asyncio
async def test_update_standalone_key_adapter_preserves_empty_restriction_lists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
existing_key = SimpleNamespace(id="sa-key-4", is_standalone=True)
_mock_query_first(db, existing_key)
updated_key = SimpleNamespace(
id="sa-key-4",
name="Standalone Key 4",
get_display_key=lambda: "sk-stand...3456",
is_active=True,
rate_limit=None,
expires_at=None,
updated_at=datetime.now(timezone.utc),
)
captured: dict[str, object] = {}
def _update_api_key(_db: MagicMock, _key_id: str, **kwargs: object) -> SimpleNamespace:
captured.update(kwargs)
return updated_key
monkeypatch.setattr(
"src.api.admin.api_keys.routes.ApiKeyService.update_api_key", _update_api_key
)
monkeypatch.setattr(
"src.api.admin.api_keys.routes._ensure_standalone_wallet",
lambda *_a, **_k: SimpleNamespace(id="wallet-2"),
)
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.serialize_wallet_summary",
lambda _wallet: {"id": "wallet-2"},
)
adapter = AdminUpdateApiKeyAdapter(
key_id="sa-key-4",
key_data=CreateApiKeyRequest(
allowed_providers=[],
allowed_api_formats=[],
allowed_models=[],
),
)
result = await adapter.handle(_build_context(db))
assert result["id"] == "sa-key-4"
assert captured["allowed_providers"] == []
assert captured["allowed_api_formats"] == []
assert captured["allowed_models"] == []