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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user