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
+72 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey
from app.models.snapshot import UpstreamRateSnapshot
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
from app.services.snapshot_service import diff_snapshots, prune_snapshots
@@ -130,7 +131,20 @@ def _check_upstream(upstream_id: int) -> None:
finally:
db.close()
# ── Phase 2: notifications (independent sessions) ──────────────
# ── Phase 2: key sync (independent session) ───────────────────
if snapshot:
captured_at = snapshot.get("captured_at")
if isinstance(captured_at, str):
from datetime import datetime as dt
try:
captured_at = dt.fromisoformat(captured_at)
except Exception:
captured_at = datetime.now(timezone.utc)
elif captured_at is None:
captured_at = datetime.now(timezone.utc)
_sync_upstream_keys(upstream_id, snapshot, captured_at)
# ── Phase 3: notifications (independent sessions) ──────────────
if was_unhealthy:
_notify_status(upstream_id, upstream.name, upstream.base_url, "upstream_recovered")
@@ -170,6 +184,63 @@ def _notify_rate_changed(
db.close()
def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at: datetime) -> None:
"""上游检测成功后同步 SmartUp Key 状态(远端删除/分组删除)。"""
db = SessionLocal()
try:
active_group_ids = set(snapshot.get("groups", {}).keys())
key_rows = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.upstream_id == upstream_id,
UpstreamGeneratedKey.key_name.like("SmartUp-%"),
)
.all()
)
auth_config = json.loads(
db.query(Upstream).filter(Upstream.id == upstream_id).first().auth_config_json or "{}"
)
# 用 UpstreamClient 查询远端活跃 Key ID 集合
remote_key_ids: set[str] | None = None # None=查询失败,set()=查询成功但为空
try:
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
if upstream:
with UpstreamClient(
base_url=upstream.base_url,
api_prefix=upstream.api_prefix,
auth_type=upstream.auth_type,
auth_config=auth_config,
timeout=float(upstream.timeout_seconds),
) as client:
client.login()
remote_keys = client.list_api_keys(search="SmartUp", status="active")
remote_key_ids = {
str(k["id"]) for k in remote_keys if k.get("id")
}
except Exception as exc:
logger.warning("sync upstream keys list failed for %s: %s", upstream_id, exc)
for row in key_rows:
# 1. 分组已不在当前快照中 → 删除本地记录
if row.group_id not in active_group_ids:
db.delete(row)
logger.info("removed key %s (group %s no longer in snapshot)", row.id, row.group_id)
continue
# 2. 远端查询成功但 key_id 不在列表中 → 删除本地记录
if row.key_id and remote_key_ids is not None and row.key_id not in remote_key_ids:
db.delete(row)
logger.info("removed key %s (key_id %s gone from remote)", row.id, row.key_id)
continue
# 3. 更新同步时间戳(仅当查询成功且 Key 仍在远端时)
if remote_key_ids is not None and row.key_id in remote_key_ids:
row.updated_at = captured_at
db.commit()
except Exception:
logger.exception("key sync failed for upstream %s", upstream_id)
finally:
db.close()
def _sync_website_bindings(upstream_id: int, changes: list[dict[str, Any]]) -> None:
db = SessionLocal()
try: