feat: 上游 Key 唯一化、分组导入跳过、账号导入平台识别&远端校验&base_url 注入

- 上游 Key 命名改为 {prefix}-{upstream.id}-{safe_group_name}-{group_id}
- 唯一约束 (upstream_id, group_id, managed_prefix) 加 managed_prefix 列
- 上游检测成功时同步 Key 状态,远端已删/分组已删自动清理
- 重复分组导入跳过,目标网站已存在同名分组返回 exists
- 账号导入平台自动识别(auto/manual 模式)
- 全选可导入 Key 按钮 + 目标分组自动匹配
- 导入幂等:已导入过的 Key 校验远端账号,不存在则重建
- 新增同步接口 POST /sync-imported-upstream-keys
- account_exists() 通过拉取账号列表判断,避免 404 误判
- credentials.base_url 注入来源上游地址,避免 401
- 前端导入弹窗自动同步+刷新按钮+并发/优先级设置
- 新增 12 个测试覆盖同步、幂等、远端删除、校验失败路径
This commit is contained in:
liumangmang
2026-05-21 01:16:39 +08:00
parent 0a27bba296
commit 6044b00685
18 changed files with 3112 additions and 50 deletions
+234 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from typing import List
@@ -14,11 +15,15 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.admin_user import AdminUser
from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey
from app.models.snapshot import UpstreamRateSnapshot
from app.schemas.upstream import (
GenerateKeysByGroupsRequest,
GenerateKeysByGroupsResponse,
GeneratedUpstreamKeyResponse,
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult
)
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret
from app.services.snapshot_service import diff_snapshots
from app.services import scheduler as sched_svc
from app.services import webhook_service
@@ -31,6 +36,38 @@ MASK = "***"
SECRET_KEYS = {"password", "token", "key", "secret"}
def _group_id(group: dict) -> str:
for key in ("id", "group_id", "groupId"):
value = group.get(key)
if value is not None:
return str(value)
return str(group.get("name") or group.get("group_name") or "")
def _group_name(group: dict, gid: str) -> str:
return str(group.get("name") or group.get("group_name") or gid)
def _key_response(row: UpstreamGeneratedKey, include_value: bool = False) -> GeneratedUpstreamKeyResponse:
return GeneratedUpstreamKeyResponse(
id=row.id,
upstream_id=row.upstream_id,
group_id=row.group_id,
group_name=row.group_name,
key_id=row.key_id,
key_name=row.key_name,
key_value=row.key_value if include_value else None,
masked_key=row.masked_key,
status=row.status,
error=row.error,
imported_website_id=row.imported_website_id,
imported_account_id=row.imported_account_id,
imported_at=row.imported_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
masked = {}
for k, v in cfg.items():
@@ -73,6 +110,198 @@ def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)):
return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()]
@router.get("/{uid}/generated-keys", response_model=List[GeneratedUpstreamKeyResponse])
def list_generated_keys(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
if not db.query(Upstream.id).filter(Upstream.id == uid).first():
raise HTTPException(404, "upstream not found")
rows = (
db.query(UpstreamGeneratedKey)
.filter(UpstreamGeneratedKey.upstream_id == uid)
.order_by(UpstreamGeneratedKey.id.desc())
.limit(200)
.all()
)
return [_key_response(row) for row in rows]
_generate_key_lock = __import__("threading").Lock()
def _ensure_group_key(
db: Session,
client: UpstreamClient,
upstream: Upstream,
group: dict[str, Any],
prefix: str,
body: GenerateKeysByGroupsRequest,
) -> GeneratedUpstreamKeyResponse:
"""确保一个上游分组有一个 SmartUp 前缀 Key:存在则 upsert,不存在则创建。"""
gid = _group_id(group)
gname = _group_name(group, gid)
# 使用稳定的 upstream_id + group_id 而非可变名称,避免因改名产生重复
# 可读 Key 名:{prefix}-{upstream.id}-{安全的分组名}-{group_id}
safe_group_name = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff_-]", "", gname)[:30] if gname else gid
stable_name = f"{prefix}-{upstream.id}-{safe_group_name}-{gid}"
with _generate_key_lock:
try:
# 1. 先查本地是否已有该分组的托管 Key(兼容迁移前无 managed_prefix 的记录)
row = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.upstream_id == upstream.id,
UpstreamGeneratedKey.group_id == gid,
(UpstreamGeneratedKey.managed_prefix == prefix)
| ((UpstreamGeneratedKey.managed_prefix.is_(None))
& UpstreamGeneratedKey.key_name.like(f"{prefix}-%")),
)
.first()
)
if row and row.key_id:
# 本地已有记录,检查远端是否仍存在
try:
existing = client.find_smartup_group_key(gid, stable_name, prefix)
except Exception:
existing = None
if existing:
row.status = "exists"
row.updated_at = datetime.now(timezone.utc)
db.commit()
return _key_response(row, include_value=False)
# 远端不存在,需要重新创建
row.status = "replaced"
# 2. 查远端是否有同名 Key(防止并发时另一个请求已创建)
existing = client.find_smartup_group_key(gid, stable_name, prefix)
if existing:
key_id = str(existing.get("id") or "")
masked = existing.get("masked_key") or existing.get("key") or ""
if row:
row.key_id = key_id or row.key_id
row.masked_key = masked or row.masked_key
row.status = "exists"
row.updated_at = datetime.now(timezone.utc)
else:
row = UpstreamGeneratedKey(
upstream_id=upstream.id,
group_id=gid,
group_name=gname,
key_id=key_id or None,
key_name=stable_name,
key_value="",
masked_key=masked,
raw_json=json.dumps(existing, ensure_ascii=False),
managed_prefix=prefix,
status="exists",
)
db.add(row)
db.commit()
db.refresh(row)
return _key_response(row, include_value=False)
# 3. 远端不存在,创建新 Key
created = client.create_api_key(
stable_name,
gid,
quota=body.quota,
expires_in_days=body.expires_in_days,
rate_limit_5h=body.rate_limit_5h,
rate_limit_1d=body.rate_limit_1d,
rate_limit_7d=body.rate_limit_7d,
endpoint=body.endpoint,
)
if row:
# 复用旧行
row.key_id = created.get("id") or None
row.key_name = stable_name
row.key_value = created["key"]
row.masked_key = created.get("masked_key") or mask_secret(created["key"])
row.raw_json = json.dumps(created.get("raw") or {}, ensure_ascii=False)
row.managed_prefix = prefix
row.status = "created"
row.error = None
else:
row = UpstreamGeneratedKey(
upstream_id=upstream.id,
group_id=gid,
group_name=gname,
key_id=created.get("id") or None,
key_name=stable_name,
key_value=created["key"],
masked_key=created.get("masked_key") or mask_secret(created["key"]),
raw_json=json.dumps(created.get("raw") or {}, ensure_ascii=False),
managed_prefix=prefix,
status="created",
)
db.add(row)
db.commit()
db.refresh(row)
return _key_response(row, include_value=True)
except Exception as exc:
logger.exception("ensure group key failed for upstream=%s group=%s", upstream.id, gid)
return GeneratedUpstreamKeyResponse(
upstream_id=upstream.id,
group_id=gid,
group_name=gname,
key_name=stable_name,
status="failed",
error=str(exc),
)
@router.post("/{uid}/keys/generate-by-groups", response_model=GenerateKeysByGroupsResponse)
def generate_keys_by_groups(
uid: int,
body: GenerateKeysByGroupsRequest,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
if u.api_prefix.strip("/") != "api/v1":
raise HTTPException(400, "首版仅支持 Sub2API 上游(API Prefix 应为 /api/v1")
auth_config = json.loads(u.auth_config_json or "{}")
selected = set(body.group_ids)
prefix = body.name_prefix
results: list[GeneratedUpstreamKeyResponse] = []
with UpstreamClient(
base_url=u.base_url,
api_prefix=u.api_prefix,
auth_type=u.auth_type,
auth_config=auth_config,
timeout=float(u.timeout_seconds),
) as client:
try:
client.login()
groups = client.get_available_groups(u.groups_endpoint)
except Exception as exc:
raise HTTPException(502, str(exc))
for group in groups:
gid = _group_id(group)
if not gid or (selected and gid not in selected):
continue
result = _ensure_group_key(db, client, u, group, prefix, body)
results.append(result)
created = len([item for item in results if item.status == "created"])
existed = len([item for item in results if item.status == "exists"])
total = len(results)
msg_parts = []
if created:
msg_parts.append(f"新创建 {created}")
if existed:
msg_parts.append(f"已存在 {existed}")
msg = "".join(msg_parts) + f" / 共 {total} 个分组" if msg_parts else f"共处理 {total} 个分组"
return GenerateKeysByGroupsResponse(
success=total > 0 and all(item.status != "failed" for item in results),
message=msg,
items=results,
)
@router.post("", response_model=UpstreamResponse, status_code=201)
def create_upstream(
body: UpstreamCreate,
@@ -255,6 +484,10 @@ def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_use
webhook_service.send_rate_changed(db, u.id, u.name, u.base_url, changes)
website_sync.sync_affected_bindings(db, u.id, changes)
# 同步 SmartUp Key 状态(使用实际快照入库时间,与定时任务一致)
from app.services.scheduler import _sync_upstream_keys as _synck
_synck(uid, snapshot, new_row.captured_at)
msg = f"检测成功,{len(groups)} 个分组"
if changes:
msg += f",发现 {len(changes)} 处倍率变化"
+420
View File
@@ -9,11 +9,21 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.schemas.website import (
BindingCreate,
BindingResponse,
BindingUpdate,
ImportAccountItem,
ImportAccountsRequest,
ImportAccountsResponse,
ImportGroupItem,
ImportGroupsRequest,
ImportGroupsResponse,
SyncImportStatusRequest,
TestResult,
WebsiteCreate,
WebsiteGroupResponse,
@@ -118,6 +128,47 @@ def _client(row: Website) -> Sub2ApiWebsiteClient:
)
def _latest_upstream_groups(db: Session, upstream_id: int) -> list[dict]:
row = (
db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
.order_by(UpstreamRateSnapshot.captured_at.desc())
.first()
)
if not row:
raise HTTPException(404, "no upstream snapshot found; run upstream check first")
snapshot = json.loads(row.snapshot_json or "{}")
groups = snapshot.get("groups") or {}
if not isinstance(groups, dict):
return []
return [item for item in groups.values() if isinstance(item, dict)]
def _source_group_id(group: dict) -> str:
return str(group.get("group_id") or group.get("id") or group.get("name") or "")
def _source_group_name(group: dict, gid: str) -> str:
return str(group.get("group_name") or group.get("name") or gid)
def _source_group_rate(group: dict) -> float:
raw = group.get("rate") or group.get("default_rate") or group.get("rate_multiplier") or 1
try:
return float(raw)
except (TypeError, ValueError):
return 1.0
def _numeric_group_id(value: str | None) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except ValueError:
return None
@router.get("/api/websites", response_model=List[WebsiteResponse])
def list_websites(db: Session = Depends(get_db), _=Depends(get_current_user)):
return [_website_response(row) for row in db.query(Website).order_by(Website.id).all()]
@@ -215,6 +266,375 @@ def list_website_groups(wid: int, db: Session = Depends(get_db), _=Depends(get_c
raise HTTPException(502, str(exc))
@router.post("/api/websites/{wid}/groups/import-from-upstream/{upstream_id}", response_model=ImportGroupsResponse)
def import_groups_from_upstream(
wid: int,
upstream_id: int,
body: ImportGroupsRequest,
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")
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
if not upstream:
raise HTTPException(404, "upstream not found")
selected = set(body.group_ids)
groups = _latest_upstream_groups(db, upstream_id)
# 拉取目标网站已有分组,同名则跳过
try:
existing_names = set()
with _client(website) as c:
for eg in c.get_groups(website.groups_endpoint):
gname = eg.get("name") or eg.get("group_name") or ""
if gname:
existing_names.add(gname)
except Exception:
existing_names = set()
items: list[ImportGroupItem] = []
with _client(website) as c:
for group in groups:
source_gid = _source_group_id(group)
if not source_gid or (selected and source_gid not in selected):
continue
source_name = _source_group_name(group, source_gid)
target_name = f"{body.name_prefix}{source_name}" if body.name_prefix else source_name
# 检查是否已存在同名分组
if target_name in existing_names:
items.append(ImportGroupItem(
source_group_id=source_gid,
source_group_name=source_name,
target_group_name=target_name,
status="exists",
message="目标分组已存在,已跳过",
))
continue
create_body = {
"name": target_name,
"description": group.get("description") or f"Imported from {upstream.name} / {source_name}",
"platform": group.get("platform") or "openai",
"rate_multiplier": _source_group_rate(group),
}
if group.get("rpm_limit") is not None:
create_body["rpm_limit"] = group.get("rpm_limit")
try:
created = c.create_group(create_body)
target_id = c.extract_id(created)
items.append(ImportGroupItem(
source_group_id=source_gid,
source_group_name=source_name,
target_group_id=target_id or None,
target_group_name=str(created.get("name") or target_name),
status="created",
message="已创建",
raw=created,
))
except Exception as exc:
msg = str(exc)
# 捕获 409 等已存在错误
if "已存在" in msg or "already exists" in msg.lower() or "409" in msg or "Conflict" in msg:
items.append(ImportGroupItem(
source_group_id=source_gid,
source_group_name=source_name,
target_group_name=target_name,
status="exists",
message="目标分组已存在(接口返回冲突)",
))
else:
logger.exception("import website group failed website=%s upstream=%s group=%s", wid, upstream_id, source_gid)
items.append(ImportGroupItem(
source_group_id=source_gid,
source_group_name=source_name,
target_group_name=target_name,
status="failed",
message=msg,
))
created_count = len([item for item in items if item.status == "created"])
exists_count = len([item for item in items if item.status == "exists"])
failed_count = len([item for item in items if item.status == "failed"])
msg_parts = []
if created_count:
msg_parts.append(f"新建 {created_count}")
if exists_count:
msg_parts.append(f"已存在 {exists_count}")
if failed_count:
msg_parts.append(f"失败 {failed_count}")
return ImportGroupsResponse(
success=failed_count == 0,
message="".join(msg_parts) + f" / 共 {len(items)}" if msg_parts else f"共处理 {len(items)} 个分组",
items=items,
)
def _detect_platform(text: str, fallback: str = "openai") -> str:
"""根据 Key 名或分组名关键词判断平台类型。"""
lower = text.lower()
if "claude" in lower or "anthropic" in lower:
return "anthropic"
if "gemini" in lower:
return "gemini"
if "antigravity" in lower:
return "antigravity"
return fallback
@router.post("/api/websites/{wid}/accounts/sync-imported-upstream-keys", response_model=ImportAccountsResponse)
def sync_imported_upstream_keys(
wid: int,
body: SyncImportStatusRequest,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
"""校验已导入的上游 Key 在目标 Sub2API 账号管理中是否仍存在。"""
website = db.query(Website).filter(Website.id == wid).first()
if not website:
raise HTTPException(404, "website not found")
rows = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.upstream_id == body.upstream_id,
UpstreamGeneratedKey.imported_website_id == wid,
UpstreamGeneratedKey.imported_account_id.isnot(None),
)
.all()
)
items: list[ImportAccountItem] = []
with _client(website) as c:
for row in rows:
platform = _detect_platform(f"{row.group_name} {row.group_id} {row.key_name}", "openai")
if not row.imported_account_id:
continue
old_account_id = row.imported_account_id
exists = c.account_exists(row.imported_account_id)
if exists is False:
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
items.append(ImportAccountItem(
upstream_key_id=row.id, source_group_id=row.group_id,
source_group_name=row.group_name, account_id=old_account_id,
platform=platform, status="stale_cleared",
message="目标账号已删除,已清除导入标记",
))
elif exists is True:
items.append(ImportAccountItem(
upstream_key_id=row.id, source_group_id=row.group_id,
source_group_name=row.group_name, account_id=old_account_id,
platform=platform, status="exists", message="目标账号仍存在",
))
else:
items.append(ImportAccountItem(
upstream_key_id=row.id, source_group_id=row.group_id,
source_group_name=row.group_name, account_id=old_account_id,
platform=platform, status="check_failed",
message="无法校验目标账号存在性(目标网站认证/网络问题)",
))
db.commit()
cleared_count = len([i for i in items if i.status == "stale_cleared"])
check_failed_count = len([i for i in items if i.status == "check_failed"])
msg_parts = []
if cleared_count:
msg_parts.append(f"清除 {cleared_count}")
if check_failed_count:
msg_parts.append(f"校验失败 {check_failed_count}")
return ImportAccountsResponse(
success=check_failed_count == 0,
message="".join(msg_parts) + f" / 共 {len(items)}" if msg_parts else f"共校验 {len(items)} 个,无变化",
items=items,
)
@router.post("/api/websites/{wid}/accounts/import-upstream-keys", response_model=ImportAccountsResponse)
def import_upstream_keys_as_accounts(
wid: int,
body: ImportAccountsRequest,
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")
if not body.upstream_key_ids:
raise HTTPException(400, "请选择要导入的 Key")
rows = (
db.query(UpstreamGeneratedKey)
.filter(UpstreamGeneratedKey.id.in_(body.upstream_key_ids))
.order_by(UpstreamGeneratedKey.id)
.all()
)
found_ids = {row.id for row in rows}
missing_ids = [kid for kid in body.upstream_key_ids if kid not in found_ids]
items: list[ImportAccountItem] = [
ImportAccountItem(
upstream_key_id=kid,
source_group_id="",
source_group_name="",
platform=body.default_platform,
status="failed",
message="key not found",
)
for kid in missing_ids
]
# 查出来源上游的 Base URL
upstream_base_url = ""
if body.upstream_key_ids:
first_row = rows[0] if rows else None
if first_row:
from app.models.upstream import Upstream as _Up
_u = db.query(_Up).filter(_Up.id == first_row.upstream_id).first()
if _u:
upstream_base_url = _u.base_url
with _client(website) as c:
for row in rows:
# 先确定平台(失败项也需要记录)
if body.platform_mode == "auto":
platform = _detect_platform(
f"{row.group_name} {row.group_id} {row.key_name}",
body.default_platform,
)
else:
platform = body.default_platform
# 幂等校验:已导入过则检查远端账号是否仍存在
if row.imported_website_id == wid and row.imported_account_id:
old_account_id = row.imported_account_id
exists = c.account_exists(row.imported_account_id)
if exists is True:
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=body.target_group_map.get(row.group_id),
account_id=old_account_id,
account_name=f"{body.account_name_prefix}-{row.group_name or row.group_id}-{row.id}",
platform=platform,
upstream_base_url=upstream_base_url,
status="exists",
message="已导入过,已跳过",
))
continue
elif exists is False:
# 远端已删除,清空标记后继续创建
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
# 继续往下走(不 continue
else:
# 校验失败,保守跳过
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=body.target_group_map.get(row.group_id),
account_id=old_account_id,
platform=platform,
status="check_failed",
message="无法校验目标账号状态,已保守跳过",
))
continue
if not row.key_value:
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
platform=platform,
status="failed",
message="该 Key 无明文值,无法导入(远端已存在 Key 不会保留明文,请重新创建或手动填入)",
))
continue
target_group_id = body.target_group_map.get(row.group_id)
group_ids = []
numeric_target = _numeric_group_id(target_group_id)
if numeric_target is not None:
group_ids.append(numeric_target)
account_name = f"{body.account_name_prefix}-{row.group_name or row.group_id}-{row.id}"
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": body.concurrency,
"priority": body.priority,
"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.status = "imported"
row.error = None
db.commit()
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=target_group_id,
account_id=account_id or None,
account_name=str(created.get("name") or account_name),
platform=platform,
upstream_base_url=upstream_base_url,
status="created",
message="已创建账号",
raw=created,
))
except Exception as exc:
logger.exception("import upstream key as account failed website=%s key=%s", wid, row.id)
row.status = "import_failed"
row.error = str(exc)
db.commit()
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=target_group_id,
account_name=account_name,
platform=platform,
upstream_base_url=upstream_base_url,
status="failed",
message=str(exc),
))
created_count = len([item for item in items if item.status == "created"])
exists_count = len([item for item in items if item.status == "exists"])
failed_count = len([item for item in items if item.status == "failed"])
check_failed_count = len([item for item in items if item.status == "check_failed"])
msg_parts = []
if created_count:
msg_parts.append(f"新建 {created_count}")
if exists_count:
msg_parts.append(f"已存在 {exists_count}")
if check_failed_count:
msg_parts.append(f"校验失败 {check_failed_count}")
if failed_count:
msg_parts.append(f"失败 {failed_count}")
return ImportAccountsResponse(
success=failed_count == 0 and check_failed_count == 0,
message="".join(msg_parts) + f" / 共 {len(items)}" if msg_parts else f"共处理 {len(items)}",
items=items,
)
@router.get("/api/group-bindings", response_model=List[BindingResponse])
def list_bindings(db: Session = Depends(get_db), _=Depends(get_current_user)):
rows = db.query(WebsiteGroupBinding).order_by(WebsiteGroupBinding.id.desc()).all()