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