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
+147
View File
@@ -62,6 +62,49 @@ def _find_user_id(value: Any) -> str:
return ""
def mask_secret(value: Any) -> str:
text = str(value or "")
if not text:
return ""
if len(text) <= 8:
return text[:2] + "****" + text[-2:] if len(text) > 4 else "****"
return text[:4] + "**********" + text[-4:]
def _unwrap_data(value: Any) -> Any:
if isinstance(value, dict) and "data" in value and ("code" in value or "message" in value):
return value.get("data")
return value
def _extract_id(value: Any) -> str:
if isinstance(value, dict):
for key in ("id", "key_id", "keyId"):
candidate = value.get(key)
if candidate is not None:
return str(candidate)
for key in ("data", "result", "key", "api_key"):
found = _extract_id(value.get(key))
if found:
return found
return ""
def _extract_key_value(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, dict):
for key in ("key", "api_key", "apiKey", "token", "value"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate:
return candidate
for key in ("data", "result", "api_key", "key"):
found = _extract_key_value(value.get(key))
if found:
return found
return ""
def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
def _normalize(lst: list) -> list[dict[str, Any]]:
out = []
@@ -360,3 +403,107 @@ class UpstreamClient:
return float(value)
except (ValueError, TypeError):
return None
def list_api_keys(
self,
search: str = "",
group_id: str | int | None = None,
status: str = "active",
endpoint: str = "/keys",
) -> list[dict[str, Any]]:
"""查询远端上游 Key 列表,支持按名称搜索、分组筛选、状态筛选。"""
params: dict[str, Any] = {}
if search:
params["search"] = search
if group_id is not None:
params["group_id"] = int(group_id) if str(group_id).isdigit() else group_id
if status:
params["status"] = status
url = self._url(endpoint)
resp = self._client.request(
"GET",
url,
params=params if params else None,
headers=self._headers(),
cookies=self._cookies,
)
resp.raise_for_status()
data = resp.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
# 尝试展开常见的包装结构
for top_key in ("data", "result", "response"):
val = data.get(top_key)
if isinstance(val, list):
return val
if isinstance(val, dict):
for inner_key in ("items", "keys", "list", "records", "data"):
inner = val.get(inner_key)
if isinstance(inner, list):
return inner
# 顶层本身就是 list-like wrapper
for key in ("items", "keys", "list", "records"):
val = data.get(key)
if isinstance(val, list):
return val
raise UpstreamError(f"unexpected keys response type: {type(data).__name__}")
def delete_api_key(self, key_id: str, endpoint: str = "/keys") -> None:
"""删除远端上游上的一个 Key。"""
self._request("DELETE", f"{endpoint}/{key_id}")
def find_smartup_group_key(
self,
group_id: str | int,
expected_name: str,
prefix: str = "SmartUp",
) -> dict[str, Any] | None:
"""查找同一上游分组下是否已存在 SmartUp 前缀的 Key。
匹配规则:key_name 等于 expected_name,且以 prefix 开头。
返回匹配到的第一个 Key,或 None。
"""
gid = int(group_id) if str(group_id).isdigit() else group_id
keys = self.list_api_keys(search=prefix, group_id=gid, status="active")
for k in keys:
name = k.get("name") or k.get("key_name") or ""
if name == expected_name:
return k
# 部分后端返回的 name 可能带空格或 trimming
if name.strip() == expected_name.strip():
return k
return None
def create_api_key(
self,
name: str,
group_id: str | int,
quota: float = 0,
expires_in_days: int | None = None,
rate_limit_5h: float = 0,
rate_limit_1d: float = 0,
rate_limit_7d: float = 0,
endpoint: str = "/keys",
) -> dict[str, Any]:
body: dict[str, Any] = {
"name": name,
"group_id": int(group_id) if str(group_id).isdigit() else group_id,
"quota": quota,
"rate_limit_5h": rate_limit_5h,
"rate_limit_1d": rate_limit_1d,
"rate_limit_7d": rate_limit_7d,
}
if expires_in_days:
body["expires_in_days"] = expires_in_days
resp = self._request("POST", endpoint, body)
data = _unwrap_data(resp)
key_value = _extract_key_value(data)
if not key_value:
raise UpstreamError("key create response did not include key")
return {
"id": _extract_id(data),
"key": key_value,
"masked_key": mask_secret(key_value),
"raw": data if isinstance(data, dict) else {"value": data},
}