Files
taiji-AI-PAD/services/mcp-server/app/heicode_client.py
T
chenchenandClaude Opus 4.7 1461051755 fix(mcp-server): correct Heicode user-models endpoint + username predcheck
Two small follow-ups to the register hardening + Heicode P4 work:

1. heicode_client.list_user_models: the path `/api/user/{id}/models`
   prescribed in §7.11.2 returns 404 `Invalid URL` on the live Heicode
   NewAPI — that path is not registered on their router. Switched to
   `/api/user/models` (no path segment), which Heicode binds to the
   `New-Api-User: 26` admin header. End-to-end P4 smoke now 4/4 with
   user 55@55.com (id=2 on Heicode): /balance /models /usage /logs.
   Future: if Heicode ships an "admin-replaces-user" path, switch back
   and pass the actual heicode_user_id.

2. routes/auth.register: previously line-744 SELECT only checked
   req.username, but line 778 falls back to email.split("@")[0] when
   blank — so two users registering with alice@foo.com and alice@bar.com
   would both clear the predcheck, then the second would IntegrityError
   on flush. Now predcheck uses `effective_username` matching what'll
   actually be inserted.

Also append §7.15 to Heicode-对接进度与待办.md:
- 4-item agent-manager / Vault / Workload-Identity audit results
- §7.13 token rotation acknowledgement
- P4 end-to-end first-pass results
- This-session internal security hardening summary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:50:20 +08:00

278 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Heicode NewAPI 出站客户端(P4)
mcp-server 通过 admin service token 调用 Heicode NewAPI(`code.xinghanlab.com`),
拉取用户视角的元数据(余额 / 模型 / 用量 / 调用日志),用于 Manager 控制台展示。
关键约束(NewAPI middleware/auth.go 强制):
- `Authorization: Bearer <admin_access_token>` — admin 身份
- `New-Api-User: <admin_user_id>` — **必须等于** access token 对应的用户 id(CSRF 检查)
→ 这意味着 admin token **不能"切身份"调 user-self 路由**(`/api/user/self*`),
必须用 admin 路由 + `?user_id=X` query 参数定位目标用户。
→ "把当前 mcp-server 用户的 email 解析成 heicode 本地 user_id" 通过 admin search 实现:
`GET /api/user/search?keyword=<email>` → cache 结果。
依据:
- Docs/Heicode-对接进度与待办.md §2.3 + §2.3.1(Heicode 团队 2026-05-07 决策 B 方案)
- Docs/Heicode-完整调用流程图.md §2.5 4 路径架构
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional, Tuple
import httpx
import structlog
from config import settings
logger = structlog.get_logger(__name__)
class HeicodeNewAPIError(Exception):
"""Heicode NewAPI 调用失败"""
def __init__(self, message: str, status_code: int = 0, body: Any = None):
super().__init__(message)
self.status_code = status_code
self.body = body
class HeicodeNewAPIClient:
"""Heicode NewAPI HTTP 客户端(admin token-based)"""
def __init__(
self,
base_url: Optional[str] = None,
admin_token: Optional[str] = None,
admin_user_id: Optional[str] = None,
timeout: Optional[int] = None,
):
self.base_url = (base_url or settings.heicode_newapi_base_url).rstrip("/")
self.admin_token = admin_token or settings.heicode_newapi_admin_token
self.admin_user_id = admin_user_id or settings.heicode_newapi_admin_user_id
self.timeout = timeout or settings.heicode_newapi_timeout
# 简单内存缓存:email → heicode local user_id(int)
# TTL 30 分钟,命中即用
self._user_id_cache: Dict[str, Tuple[int, float]] = {}
self._cache_ttl_sec = 1800
self._cache_lock = asyncio.Lock()
def is_configured(self) -> bool:
"""判断是否已配好可用的 admin token + user_id"""
return bool(self.admin_token and self.admin_user_id)
def _admin_headers(self, request_id: Optional[str] = None) -> Dict[str, str]:
"""构造 admin 调用的 headers(含 Authorization + New-Api-User)"""
if not self.is_configured():
raise HeicodeNewAPIError(
"Heicode NewAPI admin token / admin_user_id 未配置 "
"(设置 HEICODE_NEWAPI_SERVICE_TOKEN + HEICODE_NEWAPI_ADMIN_USER_ID)",
status_code=503,
)
h = {
"Authorization": f"Bearer {self.admin_token}",
"New-Api-User": str(self.admin_user_id),
"Content-Type": "application/json",
"Accept": "application/json",
}
if request_id:
h["X-Request-Id"] = request_id
return h
async def _get(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
"""统一 GET 调用"""
url = f"{self.base_url}{path}"
headers = self._admin_headers(request_id)
async with httpx.AsyncClient(timeout=self.timeout) as client:
try:
resp = await client.get(url, headers=headers, params=params)
except httpx.HTTPError as e:
logger.error(
"heicode_newapi_request_failed",
method="GET", url=url, error=str(e),
)
raise HeicodeNewAPIError(f"请求 Heicode NewAPI 失败: {e}", status_code=502)
try:
body = resp.json()
except Exception:
body = {"raw": resp.text[:500]}
if resp.status_code >= 400:
logger.warning(
"heicode_newapi_non_2xx",
method="GET", url=url, status=resp.status_code,
body_preview=str(body)[:200],
)
raise HeicodeNewAPIError(
f"Heicode NewAPI HTTP {resp.status_code}: {body}",
status_code=resp.status_code,
body=body,
)
# NewAPI 响应惯例:{"success": bool, "message": str, "data": ...}
if isinstance(body, dict) and body.get("success") is False:
raise HeicodeNewAPIError(
f"Heicode NewAPI business error: {body.get('message', '')}",
status_code=resp.status_code,
body=body,
)
return body if isinstance(body, dict) else {"raw": body}
# ==================== email → heicode user_id 解析 ====================
async def resolve_user_id_by_email(self, email: str) -> Optional[int]:
"""通过 admin search 把 email 解析成 heicode 本地 user_id(int)
命中缓存 → 直接返回;否则调 admin /api/user/search?keyword=<email> 查询。
"""
import time
if not email:
return None
email_key = email.strip().lower()
now = time.time()
async with self._cache_lock:
cached = self._user_id_cache.get(email_key)
if cached and (now - cached[1]) < self._cache_ttl_sec:
return cached[0]
try:
body = await self._get(
"/api/user/search",
params={"keyword": email_key, "group": ""},
)
except HeicodeNewAPIError as e:
logger.warning(
"heicode_user_resolve_failed",
email=email_key, error=str(e),
)
return None
# NewAPI 返回结构通常为 {"data": {"items": [...]}} 或 {"data": [...]}
data = body.get("data") if isinstance(body, dict) else None
items: List[Dict[str, Any]] = []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("items") or data.get("users") or []
# 精确匹配 email
for u in items:
if not isinstance(u, dict):
continue
if (u.get("email") or "").strip().lower() == email_key:
uid = u.get("id")
if isinstance(uid, int) and uid > 0:
async with self._cache_lock:
self._user_id_cache[email_key] = (uid, now)
return uid
# 找不到精确匹配
return None
# ==================== 用户视角元数据查询(admin 路由 + user_id 参数) ====================
async def get_user_info(self, heicode_user_id: int, request_id: Optional[str] = None) -> Dict[str, Any]:
"""GET /api/user/{id} — 用户详情(含 quota/group/status/...)"""
body = await self._get(f"/api/user/{heicode_user_id}", request_id=request_id)
return body.get("data") if isinstance(body, dict) else body
async def list_user_models(
self, heicode_user_id: int,
request_id: Optional[str] = None,
) -> List[Any]:
"""GET /api/user/models — 当前 token 持有人视角的可用模型列表。
修订历史:
- 原 `/api/models` 是 channel-dashboard 视角(key=channelId),
admin user 没渠道返回空,废弃。
- §7.11.2 曾改用 `/api/user/{id}/models`(admin 替指定用户查),
但 2026-05-12 实测 Heicode NewAPI 返回 404 `Invalid URL`,
该 path 在他们的 router 上根本没注册。
- 现切换到 `/api/user/models`(不带 user_id 段)。NewAPI 用
`New-Api-User` header(必须 == admin token 持有人 id,否则
CSRF check 拒绝)确定视角,返回 admin 视角的全量 model 列表。
后续 Heicode 那边如果上线"用户视角"接口,可以改回 path 段方案。
注意:当前实现下,所有 mcp-server 透传的用户拿到的是 admin(user 26)
视角的 models,即 NewAPI 全量。如果 Heicode 引入 group/channel 级
模型过滤,需要 Heicode 团队提供 `admin-replaces-user` 路由我方再切。
"""
# heicode_user_id 当前未直接使用 —— 仅做调试日志参考,将来如果 Heicode
# 上线 admin-replaces-user 路径会用到。保留参数避免上游 caller 改签名。
_ = heicode_user_id
body = await self._get(
"/api/user/models",
request_id=request_id,
)
data = body.get("data") if isinstance(body, dict) else body
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("items") or data.get("models") or []
return []
async def get_user_quota_dates(
self, heicode_user_id: int,
days: int = 30,
request_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""GET /api/data/users — 按用户 id 拉取最近 N 天的用量"""
body = await self._get(
"/api/data/users",
params={"user_id": heicode_user_id, "default_time": str(days)},
request_id=request_id,
)
data = body.get("data") if isinstance(body, dict) else None
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("items") or []
return []
async def get_user_logs(
self, heicode_user_id: int,
page: int = 1, page_size: int = 50,
request_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""GET /api/log/?user_id=X — 用户调用日志(admin 路由)"""
body = await self._get(
"/api/log/",
params={
"p": page,
"page_size": page_size,
"user_id": heicode_user_id,
"type": 0, # 0=全部
},
request_id=request_id,
)
data = body.get("data") if isinstance(body, dict) else None
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("items") or []
return []
# ==================== 单例 ====================
_default_client: Optional[HeicodeNewAPIClient] = None
def get_heicode_client() -> HeicodeNewAPIClient:
global _default_client
if _default_client is None:
_default_client = HeicodeNewAPIClient()
return _default_client