feat(website): 支持基于现有绑定一键整理分组,支持账号校验补齐、自愈重建与缺失 Key 提示 (含异常保守跳过及名称层级匹配)
This commit is contained in:
@@ -34,6 +34,8 @@ from app.schemas.website import (
|
|||||||
WebsiteSyncLogResponse,
|
WebsiteSyncLogResponse,
|
||||||
WebsiteUpdate,
|
WebsiteUpdate,
|
||||||
WebsiteBatchSyncResponse,
|
WebsiteBatchSyncResponse,
|
||||||
|
OrganizeGroupsItem,
|
||||||
|
OrganizeGroupsResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.website_client import Sub2ApiWebsiteClient, _extract_id
|
from app.services.website_client import Sub2ApiWebsiteClient, _extract_id
|
||||||
@@ -43,6 +45,7 @@ from app.services.website_sync import (
|
|||||||
build_rate_priority_map,
|
build_rate_priority_map,
|
||||||
reconcile_upstream_keys_full,
|
reconcile_upstream_keys_full,
|
||||||
sync_account_priorities_for_upstream,
|
sync_account_priorities_for_upstream,
|
||||||
|
latest_rate_map,
|
||||||
)
|
)
|
||||||
from app.utils.auth import get_current_user
|
from app.utils.auth import get_current_user
|
||||||
from app.utils.number import fixed_decimal_number
|
from app.utils.number import fixed_decimal_number
|
||||||
@@ -767,6 +770,378 @@ def import_upstream_keys_as_accounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/websites/{wid}/groups/organize", response_model=OrganizeGroupsResponse)
|
||||||
|
def organize_website_groups(
|
||||||
|
wid: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""一键整理分组:按现有绑定关系导入/自愈账号"""
|
||||||
|
website = db.query(Website).filter(Website.id == wid).first()
|
||||||
|
if not website:
|
||||||
|
raise HTTPException(404, "website not found")
|
||||||
|
if website.site_type != "sub2api":
|
||||||
|
raise HTTPException(400, "目前只支持 sub2api")
|
||||||
|
|
||||||
|
# 1. 读取当前网站的所有启用绑定关系
|
||||||
|
bindings = (
|
||||||
|
db.query(WebsiteGroupBinding)
|
||||||
|
.filter(WebsiteGroupBinding.website_id == wid, WebsiteGroupBinding.enabled == True)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
items: list[OrganizeGroupsItem] = []
|
||||||
|
|
||||||
|
# 2. 收集所有的 upstream_id 并执行对账,以保证本地 Key 的状态最新
|
||||||
|
upstream_ids = set()
|
||||||
|
for b in bindings:
|
||||||
|
sources = binding_sources(b)
|
||||||
|
for src in sources:
|
||||||
|
uid = src.get("upstream_id")
|
||||||
|
if uid:
|
||||||
|
upstream_ids.add(int(uid))
|
||||||
|
|
||||||
|
for uid in upstream_ids:
|
||||||
|
try:
|
||||||
|
reconcile_upstream_keys_full(db, uid)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("organize reconcile failed for upstream %s: %s", uid, exc)
|
||||||
|
|
||||||
|
# 3. 按倍率自动分配优先级
|
||||||
|
rate_priority_map: dict[str, int] = {}
|
||||||
|
try:
|
||||||
|
rate_priority_map = _build_rate_priority_map(db, upstream_ids)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with _client(website) as c:
|
||||||
|
# 获取远端分组名称列表和账号列表
|
||||||
|
target_group_names: dict[str, str] = {}
|
||||||
|
remote_accounts: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
grps = c.get_groups(endpoint=website.groups_endpoint) or []
|
||||||
|
for g in grps:
|
||||||
|
g_id = g.get("id")
|
||||||
|
if g_id is not None:
|
||||||
|
target_group_names[str(g_id)] = g.get("name") or str(g_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("failed to fetch groups for website %s at organize: %s", wid, e)
|
||||||
|
|
||||||
|
remote_account_ids: set[str] | None = None
|
||||||
|
try:
|
||||||
|
remote_accounts = c.list_accounts()
|
||||||
|
if remote_accounts is not None:
|
||||||
|
remote_account_ids = set()
|
||||||
|
for acc in remote_accounts:
|
||||||
|
acc_id = c.extract_id(acc)
|
||||||
|
if acc_id:
|
||||||
|
remote_account_ids.add(str(acc_id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("failed to fetch remote accounts for website %s at organize: %s", wid, e)
|
||||||
|
|
||||||
|
# 构建 remote_account_map 用于快速查询账号状态
|
||||||
|
remote_account_map = {}
|
||||||
|
if remote_accounts is not None:
|
||||||
|
for acc in remote_accounts:
|
||||||
|
acc_id = c.extract_id(acc)
|
||||||
|
if acc_id:
|
||||||
|
remote_account_map[str(acc_id)] = acc
|
||||||
|
|
||||||
|
# 对每个绑定关系遍历其对应的上游 Key 进行整理
|
||||||
|
for b in bindings:
|
||||||
|
target_group_id = b.target_group_id
|
||||||
|
target_group_name = target_group_names.get(str(target_group_id)) or b.target_group_name or str(target_group_id)
|
||||||
|
sources = binding_sources(b)
|
||||||
|
|
||||||
|
for src in sources:
|
||||||
|
uid = src.get("upstream_id")
|
||||||
|
gid = src.get("group_id")
|
||||||
|
if not uid or not gid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
upstream_name = db.query(Upstream.name).filter(Upstream.id == uid).scalar() or f"#{uid}"
|
||||||
|
|
||||||
|
# 来源名称优先取 src.group_name,再 fallback 快照,再 fallback ID
|
||||||
|
source_group_name = src.get("group_name") or ""
|
||||||
|
if not source_group_name:
|
||||||
|
snapshot_groups = {}
|
||||||
|
try:
|
||||||
|
snapshot_groups = latest_rate_map(db, uid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if gid in snapshot_groups:
|
||||||
|
g_data = snapshot_groups[gid]
|
||||||
|
if isinstance(g_data, dict):
|
||||||
|
source_group_name = g_data.get("name") or g_data.get("group_name") or ""
|
||||||
|
if not source_group_name:
|
||||||
|
source_group_name = gid
|
||||||
|
|
||||||
|
# 查询该 (upstream_id, group_id) 的非 orphaned Key
|
||||||
|
keys = (
|
||||||
|
db.query(UpstreamGeneratedKey)
|
||||||
|
.filter(
|
||||||
|
UpstreamGeneratedKey.upstream_id == uid,
|
||||||
|
UpstreamGeneratedKey.group_id == gid,
|
||||||
|
UpstreamGeneratedKey.status != "orphaned",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name="",
|
||||||
|
account_id=None,
|
||||||
|
account_name=None,
|
||||||
|
status="missing_key",
|
||||||
|
message="请先生成上游 Key",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 查询来源上游的 Base URL 用于账号创建
|
||||||
|
upstream_base_url = ""
|
||||||
|
_u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||||
|
if _u:
|
||||||
|
upstream_base_url = _u.base_url
|
||||||
|
|
||||||
|
for row in keys:
|
||||||
|
# 跳过状态为 failed 的 Key
|
||||||
|
if row.status == "failed":
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
account_id=None,
|
||||||
|
account_name=None,
|
||||||
|
status="failed",
|
||||||
|
message="上游 Key 生成状态为失败",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检测平台类型
|
||||||
|
platform = _detect_platform(
|
||||||
|
f"{row.group_name} {row.group_id} {row.key_name}",
|
||||||
|
"openai",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 幂等校验:已导入过则检查远端账号是否仍存在
|
||||||
|
if row.imported_website_id == wid and row.imported_account_id:
|
||||||
|
old_account_id = row.imported_account_id
|
||||||
|
|
||||||
|
if remote_account_ids is None:
|
||||||
|
# 账号列表获取失败,无法校验状态,保守跳过
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
account_id=old_account_id,
|
||||||
|
status="failed",
|
||||||
|
message="无法校验目标账号状态,已保守跳过",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
remote_acc = remote_account_map.get(str(old_account_id))
|
||||||
|
|
||||||
|
if remote_acc is not None:
|
||||||
|
# 目标账号存在:跳过或补齐目标分组记录
|
||||||
|
try:
|
||||||
|
current_group_ids = remote_acc.get("group_ids") or []
|
||||||
|
if not isinstance(current_group_ids, list):
|
||||||
|
current_group_ids = [current_group_ids]
|
||||||
|
str_group_ids = [str(g) for g in current_group_ids]
|
||||||
|
|
||||||
|
if str(target_group_id) not in str_group_ids:
|
||||||
|
# 需要补齐绑定关系
|
||||||
|
numeric_target = _numeric_group_id(target_group_id)
|
||||||
|
target_val = numeric_target if numeric_target is not None else target_group_id
|
||||||
|
|
||||||
|
new_group_ids = list(current_group_ids)
|
||||||
|
if target_val not in new_group_ids:
|
||||||
|
new_group_ids.append(target_val)
|
||||||
|
|
||||||
|
c.update_account(old_account_id, {"group_ids": new_group_ids})
|
||||||
|
# 顺便更新下 remote_account_map 中的数据,防止同个账号后续在其他绑定中重复补齐
|
||||||
|
remote_acc["group_ids"] = new_group_ids
|
||||||
|
msg = "账号已存在,已补齐目标分组绑定"
|
||||||
|
else:
|
||||||
|
msg = "已存在且已绑定,已跳过"
|
||||||
|
|
||||||
|
# 补齐本地 DB 的目标分组标记
|
||||||
|
if row.imported_target_group_id != target_group_id:
|
||||||
|
row.imported_target_group_id = target_group_id
|
||||||
|
row.imported_target_group_name = target_group_names.get(str(target_group_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
account_id=old_account_id,
|
||||||
|
account_name=str(remote_acc.get("name") or ""),
|
||||||
|
status="exists",
|
||||||
|
message=msg,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
account_id=old_account_id,
|
||||||
|
status="failed",
|
||||||
|
message=f"更新绑定失败: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 远端已删除,清理标记后进行重建
|
||||||
|
row.imported_website_id = None
|
||||||
|
row.imported_account_id = None
|
||||||
|
row.imported_at = None
|
||||||
|
row.status = "created"
|
||||||
|
db.commit()
|
||||||
|
is_recreated = True
|
||||||
|
else:
|
||||||
|
is_recreated = False
|
||||||
|
|
||||||
|
# 2. 检查是否有明文 Key
|
||||||
|
if not row.key_value:
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
status="failed",
|
||||||
|
message="该 Key 无明文值,无法导入",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. 创建账号并绑定当前目标分组
|
||||||
|
group_ids = []
|
||||||
|
numeric_target = _numeric_group_id(target_group_id)
|
||||||
|
if numeric_target is not None:
|
||||||
|
group_ids.append(numeric_target)
|
||||||
|
else:
|
||||||
|
group_ids.append(target_group_id)
|
||||||
|
|
||||||
|
account_name = f"SmartUp-{row.group_name or row.group_id}-{row.id}"
|
||||||
|
priority_val = rate_priority_map.get(f"{row.upstream_id}:{row.group_id}", 1)
|
||||||
|
account_body = {
|
||||||
|
"name": account_name,
|
||||||
|
"platform": platform,
|
||||||
|
"type": "apikey",
|
||||||
|
"credentials": {
|
||||||
|
"api_key": row.key_value,
|
||||||
|
"base_url": upstream_base_url,
|
||||||
|
},
|
||||||
|
"group_ids": group_ids,
|
||||||
|
"rate_multiplier": 1,
|
||||||
|
"concurrency": 10,
|
||||||
|
"priority": priority_val,
|
||||||
|
"notes": f"Imported by SmartUp from upstream key #{row.id}",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
created = c.create_account(account_body)
|
||||||
|
account_id = c.extract_id(created)
|
||||||
|
row.imported_website_id = wid
|
||||||
|
row.imported_account_id = account_id or None
|
||||||
|
row.imported_at = datetime.now(timezone.utc)
|
||||||
|
row.imported_target_group_id = target_group_id or None
|
||||||
|
row.imported_target_group_name = target_group_names.get(str(target_group_id)) if target_group_id else None
|
||||||
|
row.status = "imported"
|
||||||
|
row.error = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 将新创建的账号加入 remote_account_map 缓存,以防同账号的其它绑定关系后续做幂等校验
|
||||||
|
if account_id:
|
||||||
|
remote_account_map[str(account_id)] = created
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
account_id=account_id or None,
|
||||||
|
account_name=str(created.get("name") or account_name),
|
||||||
|
status="recreated" if is_recreated else "created",
|
||||||
|
message="清理后重建账号" if is_recreated else "已创建账号",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("organize create account failed website=%s key=%s", wid, row.id)
|
||||||
|
row.status = "import_failed"
|
||||||
|
row.error = str(exc)
|
||||||
|
db.commit()
|
||||||
|
items.append(
|
||||||
|
OrganizeGroupsItem(
|
||||||
|
target_group_id=str(target_group_id),
|
||||||
|
target_group_name=target_group_name,
|
||||||
|
upstream_name=upstream_name,
|
||||||
|
source_group_id=str(gid),
|
||||||
|
source_group_name=str(source_group_name),
|
||||||
|
key_name=row.key_name,
|
||||||
|
status="failed",
|
||||||
|
message=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
created_count = sum(1 for item in items if item.status == "created")
|
||||||
|
exists_count = sum(1 for item in items if item.status == "exists")
|
||||||
|
missing_key_count = sum(1 for item in items if item.status == "missing_key")
|
||||||
|
recreated_count = sum(1 for item in items if item.status == "recreated")
|
||||||
|
failed_count = sum(1 for item in items if item.status == "failed")
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
total_created = created_count + recreated_count
|
||||||
|
if total_created:
|
||||||
|
parts.append(f"创建账号 {total_created}")
|
||||||
|
if exists_count:
|
||||||
|
parts.append(f"已存在 {exists_count}")
|
||||||
|
if missing_key_count:
|
||||||
|
parts.append(f"缺少 Key {missing_key_count}")
|
||||||
|
if failed_count:
|
||||||
|
parts.append(f"失败 {failed_count}")
|
||||||
|
|
||||||
|
message = "整理完成:" + " / ".join(parts) if parts else "整理完成:无任何绑定或数据"
|
||||||
|
return OrganizeGroupsResponse(
|
||||||
|
success=failed_count == 0,
|
||||||
|
message=message,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/group-bindings", response_model=List[BindingResponse])
|
@router.get("/api/group-bindings", response_model=List[BindingResponse])
|
||||||
def list_bindings(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
def list_bindings(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||||
rows = db.query(WebsiteGroupBinding).order_by(WebsiteGroupBinding.id.desc()).all()
|
rows = db.query(WebsiteGroupBinding).order_by(WebsiteGroupBinding.id.desc()).all()
|
||||||
|
|||||||
@@ -213,3 +213,22 @@ class WebsiteBatchSyncResponse(BaseModel):
|
|||||||
skipped: int
|
skipped: int
|
||||||
message: str
|
message: str
|
||||||
logs: list[WebsiteSyncLogResponse]
|
logs: list[WebsiteSyncLogResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizeGroupsItem(BaseModel):
|
||||||
|
target_group_id: str
|
||||||
|
target_group_name: str
|
||||||
|
upstream_name: str
|
||||||
|
source_group_id: str
|
||||||
|
source_group_name: str
|
||||||
|
key_name: Optional[str] = None
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
account_name: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizeGroupsResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
items: list[OrganizeGroupsItem]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -316,6 +316,19 @@ export interface WebsiteBatchSyncResponse {
|
|||||||
logs: WebsiteSyncLog[]
|
logs: WebsiteSyncLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrganizeGroupsItem {
|
||||||
|
target_group_id: string
|
||||||
|
target_group_name: string
|
||||||
|
upstream_name: string
|
||||||
|
source_group_id: string
|
||||||
|
source_group_name: string
|
||||||
|
key_name: string | null
|
||||||
|
account_id: string | null
|
||||||
|
account_name: string | null
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export const websitesApi = {
|
export const websitesApi = {
|
||||||
list: () => api.get<WebsiteData[]>('/api/websites'),
|
list: () => api.get<WebsiteData[]>('/api/websites'),
|
||||||
create: (data: WebsiteForm) => api.post<WebsiteData>('/api/websites', data),
|
create: (data: WebsiteForm) => api.post<WebsiteData>('/api/websites', data),
|
||||||
@@ -339,6 +352,8 @@ export const websitesApi = {
|
|||||||
priority?: number
|
priority?: number
|
||||||
auto_priority_by_rate?: boolean
|
auto_priority_by_rate?: boolean
|
||||||
}) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data),
|
}) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data),
|
||||||
|
organizeGroups: (id: number) =>
|
||||||
|
api.post<{ success: boolean; message: string; items: OrganizeGroupsItem[] }>(`/api/websites/${id}/groups/organize`),
|
||||||
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
|
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
|
||||||
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
||||||
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
|
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
|
||||||
|
|||||||
@@ -111,6 +111,16 @@
|
|||||||
>
|
>
|
||||||
一键同步
|
一键同步
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
:loading="organizeLoading"
|
||||||
|
:disabled="!selectedWebsite || selectedWebsiteBindings.length === 0"
|
||||||
|
@click="organizeWebsiteGroups"
|
||||||
|
title="按当前网站的现有绑定关系一键整理分组并导入账号"
|
||||||
|
>
|
||||||
|
一键整理分组
|
||||||
|
</el-button>
|
||||||
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="binding-list" v-loading="bindingLoading">
|
<div class="binding-list" v-loading="bindingLoading">
|
||||||
@@ -449,6 +459,58 @@
|
|||||||
<el-button type="primary" :loading="importingAccounts" :disabled="importingAccounts" @click="submitImportAccounts">创建账号管理账号</el-button>
|
<el-button type="primary" :loading="importingAccounts" :disabled="importingAccounts" @click="submitImportAccounts">创建账号管理账号</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="organizeDialog" title="一键整理分组结果" width="850px" destroy-on-close>
|
||||||
|
<div v-if="organizeMessage" style="margin-bottom:12px; font-weight:bold; color:var(--el-color-primary)">
|
||||||
|
{{ organizeMessage }}
|
||||||
|
</div>
|
||||||
|
<el-table :data="organizeResults" size="small" border style="width:100%">
|
||||||
|
<el-table-column label="目标分组" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.target_group_name }}</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.target_group_id }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="来源上游/分组" min-width="165">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.upstream_name }}</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted)">{{ row.source_group_name }} (ID: {{ row.source_group_id }})</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上游 Key" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.key_name">{{ row.key_name }}</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标账号" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.account_id">
|
||||||
|
<div>{{ row.account_name }}</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.account_id }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.status === 'created'" size="small" type="success">已创建</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 'recreated'" size="small" type="warning">清理后重建</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 'missing_key'" size="small" type="danger">缺少 Key</el-tag>
|
||||||
|
<el-tag v-else size="small" type="danger">失败</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="message" label="说明/原因" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'error-text': row.status === 'failed' }">{{ row.message }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="organizeDialog = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -472,6 +534,7 @@ import {
|
|||||||
type WebsiteForm,
|
type WebsiteForm,
|
||||||
type WebsiteGroup,
|
type WebsiteGroup,
|
||||||
type WebsiteSyncLog,
|
type WebsiteSyncLog,
|
||||||
|
type OrganizeGroupsItem,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
|
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
|
||||||
@@ -576,6 +639,11 @@ const importAccountsForm = ref({
|
|||||||
})
|
})
|
||||||
const importAccountResults = ref<ImportAccountItem[]>([])
|
const importAccountResults = ref<ImportAccountItem[]>([])
|
||||||
|
|
||||||
|
const organizeDialog = ref(false)
|
||||||
|
const organizeLoading = ref(false)
|
||||||
|
const organizeResults = ref<OrganizeGroupsItem[]>([])
|
||||||
|
const organizeMessage = ref('')
|
||||||
|
|
||||||
const upstreamGroupOptions = computed(() => {
|
const upstreamGroupOptions = computed(() => {
|
||||||
const rows: Array<{ key: string; label: string; source: BindingSourceGroup }> = []
|
const rows: Array<{ key: string; label: string; source: BindingSourceGroup }> = []
|
||||||
for (const upstream of upstreams.value) {
|
for (const upstream of upstreams.value) {
|
||||||
@@ -1000,6 +1068,33 @@ async function syncAllWebsiteBindings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function organizeWebsiteGroups() {
|
||||||
|
if (!selectedWebsite.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'一键整理分组将根据当前的“分组管理”绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?',
|
||||||
|
'一键整理分组',
|
||||||
|
{ type: 'info' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
organizeLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await websitesApi.organizeGroups(selectedWebsite.value.id)
|
||||||
|
organizeMessage.value = res.data.message
|
||||||
|
organizeResults.value = res.data.items
|
||||||
|
organizeDialog.value = true
|
||||||
|
// 刷新相关数据
|
||||||
|
await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()])
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '一键整理分组失败')
|
||||||
|
} finally {
|
||||||
|
organizeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleBinding(row: GroupBindingData) {
|
async function toggleBinding(row: GroupBindingData) {
|
||||||
try {
|
try {
|
||||||
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
|
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
|
||||||
|
|||||||
Reference in New Issue
Block a user