feat(website): 支持基于现有绑定一键整理分组,支持账号校验补齐、自愈重建与缺失 Key 提示 (含异常保守跳过及名称层级匹配)

This commit is contained in:
liumangmang
2026-07-01 11:21:20 +08:00
parent bf5470caf3
commit 397a14c978
5 changed files with 828 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey
from app.models.website import Website, WebsiteGroupBinding
from app.routers.websites import organize_website_groups
@pytest.fixture()
def db_session():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# Ensure all models are created
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def test_organize_groups_full_scenarios(db_session, monkeypatch):
"""测试一键整理分组的各种核心场景:
1. 已有 Key 未导入时,创建目标账号并绑定目标分组
2. 已导入且目标账号存在时,不重复创建(若未绑定目标分组则补齐绑定)
3. 已导入但目标账号不存在时,清理旧标记并重建
4. 绑定关系存在但没有对应 Key 时,返回 missing_key
5. 多个目标分组、多上游分组绑定时,正确分类
"""
# 1. 建立测试 Website 和 Upstream
w = Website(
name="W1",
site_type="sub2api",
base_url="http://w1",
enabled=True,
auth_config_json="{}",
timeout_seconds=30
)
u1 = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u1])
db_session.commit()
db_session.refresh(w)
db_session.refresh(u1)
# 2. 建立绑定关系
# 绑定 1:目标分组 TG1 ↔ 上游分组 G1(有 Key 未导入),G2(没有 Key — 对应 missing_key
b1 = WebsiteGroupBinding(
website_id=w.id,
target_group_id="TG1",
target_group_name="TG1-Group",
source_groups_json=json.dumps([
{"upstream_id": u1.id, "group_id": "G1"},
{"upstream_id": u1.id, "group_id": "G2"},
]),
enabled=True
)
# 绑定 2:目标分组 TG2 ↔ 上游分组 G3(已导入且账号仍存在),G4(已导入但账号已删除)
b2 = WebsiteGroupBinding(
website_id=w.id,
target_group_id="TG2",
target_group_name="TG2-Group",
source_groups_json=json.dumps([
{"upstream_id": u1.id, "group_id": "G3"},
{"upstream_id": u1.id, "group_id": "G4"},
]),
enabled=True
)
db_session.add_all([b1, b2])
db_session.commit()
# 3. 建立上游 Key 记录
# G1: 未导入 Key
k1 = UpstreamGeneratedKey(
upstream_id=u1.id,
group_id="G1",
group_name="G1-Name",
key_name="Key-G1",
key_value="sk-g1-secret",
status="created",
)
# G3: 已导入且账号存在
k3 = UpstreamGeneratedKey(
upstream_id=u1.id,
group_id="G3",
group_name="G3-Name",
key_name="Key-G3",
key_value="sk-g3-secret",
status="imported",
imported_website_id=w.id,
imported_account_id="ACC-G3",
imported_target_group_id="TG2",
)
# G4: 已导入但账号已删除
k4 = UpstreamGeneratedKey(
upstream_id=u1.id,
group_id="G4",
group_name="G4-Name",
key_name="Key-G4",
key_value="sk-g4-secret",
status="imported",
imported_website_id=w.id,
imported_account_id="ACC-G4-DELETED",
imported_target_group_id="TG2",
)
db_session.add_all([k1, k3, k4])
db_session.commit()
# 4. Mock Sub2ApiWebsiteClient 交互
created_accounts = []
updated_accounts = []
class MockClient:
def __init__(self, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *a):
pass
def get_groups(self, *a, **kw):
return [
{"id": "TG1", "name": "TG1-Group"},
{"id": "TG2", "name": "TG2-Group"},
]
def list_accounts(self):
# ACC-G3 仍存在,ACC-G4-DELETED 不在此列表中代表已被删除
# 另外,我们假设 ACC-G3 目前绑定的 group_ids 是 [999],即不包含 TG2 (或整型数值)
return [
{
"id": "ACC-G3",
"name": "SmartUp-G3-ACC",
"group_ids": [999],
}
]
def extract_id(self, val):
return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body):
# 模拟创建账号
acc_id = f"NEW-{body['name']}"
new_acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"]}
created_accounts.append(new_acc)
return new_acc
def update_account(self, account_id, body):
# 模拟更新账号的分组绑定
updated_accounts.append((account_id, body))
return {"id": account_id, "group_ids": body.get("group_ids")}
monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient)
# 5. 执行一键整理
response = organize_website_groups(wid=w.id, db=db_session)
# 6. 断言结果
assert response.success is True
# 整理完成:创建账号 2 (k1创建 + k4重建) / 已存在 1 (k3已存在并补齐) / 缺少 Key 1 (G2缺少)
assert "创建账号 2" in response.message
assert "已存在 1" in response.message
assert "缺少 Key 1" in response.message
items = response.items
assert len(items) == 4
# 按 target_group_id 分类校验
# TG1 分组下的 G1 (已创建)
item_g1 = next(item for item in items if item.target_group_id == "TG1" and item.source_group_id == "G1")
assert item_g1.status == "created"
assert item_g1.key_name == "Key-G1"
assert item_g1.account_id.startswith("NEW-SmartUp-")
# TG1 分组下的 G2 (缺少 Key)
item_g2 = next(item for item in items if item.target_group_id == "TG1" and item.source_group_id == "G2")
assert item_g2.status == "missing_key"
assert item_g2.message == "请先生成上游 Key"
# TG2 分组下的 G3 (已存在并补齐)
item_g3 = next(item for item in items if item.target_group_id == "TG2" and item.source_group_id == "G3")
assert item_g3.status == "exists"
assert "已补齐目标分组绑定" in item_g3.message
# 校验是否触发了 update_account 补齐了 TG2
assert len(updated_accounts) == 1
assert updated_accounts[0][0] == "ACC-G3"
# TG2 (或者其数值) 应该在更新后的 group_ids 中
assert "TG2" in updated_accounts[0][1]["group_ids"] or 999 in updated_accounts[0][1]["group_ids"]
# TG2 分组下的 G4 (清理后重建)
item_g4 = next(item for item in items if item.target_group_id == "TG2" and item.source_group_id == "G4")
assert item_g4.status == "recreated"
assert item_g4.message == "清理后重建账号"
assert item_g4.account_id.startswith("NEW-SmartUp-")
# 7. 检查数据库中 Key 记录的状态变化
db_session.refresh(k1)
db_session.refresh(k3)
db_session.refresh(k4)
assert k1.imported_website_id == w.id
assert k1.imported_account_id is not None
assert k1.imported_target_group_id == "TG1"
assert k1.status == "imported"
assert k3.imported_target_group_id == "TG2"
assert k4.imported_website_id == w.id
assert k4.imported_account_id is not None
assert k4.imported_target_group_id == "TG2"
assert k4.status == "imported"
def test_organize_groups_list_accounts_none_conservatively_skips(db_session, monkeypatch):
"""测试一键整理分组时,若远端账号列表拉取失败返回 None:
- 已导入的账号不得被清除或重建,而应该保守跳过(状态记为 failed,提示无法校验)。
- 未导入的账号仍能正常导入创建。
"""
w = Website(
name="W1",
site_type="sub2api",
base_url="http://w1",
enabled=True,
auth_config_json="{}",
timeout_seconds=30
)
u1 = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u1])
db_session.commit()
db_session.refresh(w)
db_session.refresh(u1)
# 绑定关系:目标分组 TG1 ↔ 上游分组 G1(未导入),G3(已导入)
b1 = WebsiteGroupBinding(
website_id=w.id,
target_group_id="TG1",
target_group_name="TG1-Group",
source_groups_json=json.dumps([
{"upstream_id": u1.id, "group_id": "G1", "group_name": "G1-SourceGroup"},
{"upstream_id": u1.id, "group_id": "G3", "group_name": "G3-SourceGroup"},
]),
enabled=True
)
db_session.add_all([b1])
db_session.commit()
# k1: 未导入 Key
k1 = UpstreamGeneratedKey(
upstream_id=u1.id,
group_id="G1",
group_name="G1-SourceGroup",
key_name="Key-G1",
key_value="sk-g1-secret",
status="created",
)
# k3: 已导入 Key
k3 = UpstreamGeneratedKey(
upstream_id=u1.id,
group_id="G3",
group_name="G3-SourceGroup",
key_name="Key-G3",
key_value="sk-g3-secret",
status="imported",
imported_website_id=w.id,
imported_account_id="ACC-G3-EXISTING",
imported_target_group_id="TG1",
)
db_session.add_all([k1, k3])
db_session.commit()
created_accounts = []
class MockClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *a): pass
def get_groups(self, *a, **kw):
return [{"id": "TG1", "name": "TG1-Group"}]
def list_accounts(self):
# 模拟获取失败返回 None
return None
def extract_id(self, val):
return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body):
acc_id = f"NEW-{body['name']}"
new_acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"]}
created_accounts.append(new_acc)
return new_acc
monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient)
# 执行一键整理
response = organize_website_groups(wid=w.id, db=db_session)
# 断言结果:由于其中有 1 个已导入账号校验失败,response.success 应为 False(有失败项)
assert response.success is False
# 统计信息中应该有“创建账号 1”与“失败 1”
assert "创建账号 1" in response.message
assert "失败 1" in response.message
items = response.items
assert len(items) == 2
# G1 (未导入) 仍应该成功创建账号
item_g1 = next(item for item in items if item.source_group_id == "G1")
assert item_g1.status == "created"
assert item_g1.account_id.startswith("NEW-SmartUp-")
assert item_g1.source_group_name == "G1-SourceGroup" # 校验 group_name 优先从绑定中获取!
# G3 (已导入) 应该保守跳过
item_g3 = next(item for item in items if item.source_group_id == "G3")
assert item_g3.status == "failed"
assert item_g3.message == "无法校验目标账号状态,已保守跳过"
# 校验 DB 中的 k3 导入标记和状态绝对不能被清除/变动!
db_session.refresh(k3)
assert k3.imported_website_id == w.id
assert k3.imported_account_id == "ACC-G3-EXISTING"
assert k3.status == "imported"