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

@@ -0,0 +1,159 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from src.services.provider import delete_cleanup as cleanup_module
from src.services.provider.delete_cleanup import (
delete_provider_tree,
prune_allowed_provider_list,
prune_allowed_provider_refs,
)
def _query_mock(
*,
rows: list[object] | None = None,
update_count: int | None = None,
delete_count: int | None = None,
) -> MagicMock:
query = MagicMock()
filtered = query.filter.return_value
if rows is not None:
filtered.all.return_value = rows
if update_count is not None:
filtered.update.return_value = update_count
if delete_count is not None:
filtered.delete.return_value = delete_count
return query
def test_prune_allowed_provider_list_removes_target_and_normalizes_empty() -> None:
next_allowed, changed = prune_allowed_provider_list(["provider-a", "provider-b"], "provider-a")
assert changed is True
assert next_allowed == ["provider-b"]
next_allowed, changed = prune_allowed_provider_list(["provider-a"], "provider-a")
assert changed is True
assert next_allowed == []
def test_prune_allowed_provider_refs_updates_matching_records_only() -> None:
records = [
SimpleNamespace(allowed_providers=["provider-a", "provider-b"]),
SimpleNamespace(allowed_providers=["provider-b"]),
SimpleNamespace(allowed_providers=None),
]
updated = prune_allowed_provider_refs(records, "provider-a")
assert updated == 1
assert records[0].allowed_providers == ["provider-b"]
assert records[1].allowed_providers == ["provider-b"]
assert records[2].allowed_providers is None
@pytest.mark.asyncio
async def test_cleanup_deleted_provider_references_cleans_large_fanout_tables(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
user_records = [SimpleNamespace(allowed_providers=["provider-a", "provider-b"])]
api_key_records = [SimpleNamespace(allowed_providers=["provider-a"])]
key_cleanup_calls: list[list[str]] = []
monkeypatch.setattr(
cleanup_module,
"cleanup_key_references",
lambda _db, key_ids: key_cleanup_calls.append(list(key_ids)),
)
db.query.side_effect = [
_query_mock(rows=[("endpoint-1",), ("endpoint-2",)]),
_query_mock(rows=[("key-1",), ("key-2",)]),
_query_mock(rows=user_records),
_query_mock(rows=api_key_records),
_query_mock(update_count=2),
_query_mock(update_count=3),
_query_mock(update_count=4),
_query_mock(update_count=5),
_query_mock(update_count=6),
_query_mock(delete_count=7),
_query_mock(delete_count=8),
]
stats = cleanup_module.cleanup_deleted_provider_references(db, "provider-a")
assert stats == {
"users": 1,
"api_keys": 1,
"user_preferences": 2,
"usage_provider": 3,
"usage_endpoint": 5,
"video_tasks_provider": 4,
"video_tasks_endpoint": 6,
"request_candidates_provider": 8,
"request_candidates_endpoint": 7,
}
assert user_records[0].allowed_providers == ["provider-b"]
assert api_key_records[0].allowed_providers == []
assert key_cleanup_calls == [["key-1", "key-2"]]
def test_delete_provider_tree_deletes_children_before_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
cleanup_mock = MagicMock(
return_value={
"users": 1,
"api_keys": 2,
"user_preferences": 3,
"usage_provider": 4,
"usage_endpoint": 5,
"video_tasks_provider": 6,
"video_tasks_endpoint": 7,
"request_candidates_provider": 8,
"request_candidates_endpoint": 9,
}
)
monkeypatch.setattr(cleanup_module, "cleanup_deleted_provider_references", cleanup_mock)
db.query.side_effect = [
_query_mock(rows=[("endpoint-1",), ("endpoint-2",)]),
_query_mock(rows=[("key-1",), ("key-2",), ("key-3",)]),
_query_mock(delete_count=10),
_query_mock(delete_count=11),
_query_mock(delete_count=12),
_query_mock(delete_count=13),
_query_mock(delete_count=14),
_query_mock(delete_count=1),
]
stats = delete_provider_tree(db, "provider-a")
cleanup_mock.assert_called_once_with(
db,
"provider-a",
endpoint_ids=["endpoint-1", "endpoint-2"],
key_ids=["key-1", "key-2", "key-3"],
)
assert stats == {
"cleanup": cleanup_mock.return_value,
"deleted": {
"api_key_mappings": 10,
"usage_tracking": 11,
"models": 12,
"api_keys": 13,
"endpoints": 14,
"providers": 1,
},
"key_count": 3,
"endpoint_count": 2,
}