From 448401427fbe68e0691ab15f26d7ea2d69bf99cd Mon Sep 17 00:00:00 2001 From: chenchen Date: Wed, 15 Jul 2026 16:30:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp-server):=20=E4=BC=81=E4=B8=9A=E9=82=80?= =?UTF-8?q?=E8=AF=B7=20org-invite-email=20=E7=AB=AF=E7=82=B9=20+=20magic-l?= =?UTF-8?q?ink=20landing=20web=20=E6=A8=A1=E5=BC=8F(Q2-B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- services/mcp-server/app/auth.py | 1 + services/mcp-server/app/magic_link.py | 73 ++++++++++++++++++++-- services/mcp-server/app/routes/auth.py | 85 +++++++++++++++++++++++++- 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/services/mcp-server/app/auth.py b/services/mcp-server/app/auth.py index 486e778..618d0dc 100644 --- a/services/mcp-server/app/auth.py +++ b/services/mcp-server/app/auth.py @@ -208,6 +208,7 @@ async def require_auth( "/api/auth/internal/billing-provider", # Heicode §7.8.1 内部端点(用 HEICODE_INTERNAL_SERVICE_TOKEN 鉴权,不走 user JWT) "/api/auth/internal/approvals", # Heicode §7.8.3 内部端点(同上) "/api/auth/internal/provision", # Heicode 企业邀请 §Q1-b 内部端点(同上) + "/api/auth/internal/org-invite-email", # Heicode 企业邀请 §Q2-B 内部端点(同上) "/agents/templates", # 模板列表公开访问 } # 允许公开路径和非 API/agents 路径 diff --git a/services/mcp-server/app/magic_link.py b/services/mcp-server/app/magic_link.py index 08e1419..28ea464 100644 --- a/services/mcp-server/app/magic_link.py +++ b/services/mcp-server/app/magic_link.py @@ -145,10 +145,26 @@ async def set_email_rate_limit(email: str) -> None: # ===== token:email + state 绑定 ===== -async def store_magic_link_token(token: str, email: str, state: str) -> bool: - """存 magic-link token → {email, state},TTL 600s。""" - payload = json.dumps({"email": email, "state": state}) - return await _setex(_TOKEN_KEY_PREFIX + token, MAGIC_LINK_TOKEN_TTL_SECONDS, payload) +async def store_magic_link_token( + token: str, + email: str, + state: str, + web_callback: Optional[str] = None, + accept_url: Optional[str] = None, + ttl: int = MAGIC_LINK_TOKEN_TTL_SECONDS, +) -> bool: + """存 magic-link token → {email, state[, web_callback, accept_url]},TTL 默认 600s。 + + - 桌面登录:只传 email/state(landing 302 到 heicode://)。 + - 企业邀请 web 模式(Q2-B):额外传 web_callback + accept_url(landing 302 到 + web_callback?code=&state=&redirect=accept_url),并可用更长 ttl(邀请信可能隔时点)。 + """ + data = {"email": email, "state": state} + if web_callback: + data["web_callback"] = web_callback + if accept_url: + data["accept_url"] = accept_url + return await _setex(_TOKEN_KEY_PREFIX + token, ttl, json.dumps(data)) async def consume_magic_link_token(token: str) -> Optional[dict]: @@ -256,3 +272,52 @@ Taiji AI-PAD 团队 logger.error("magic_link_email_send_failed", email=email, error=str(e), error_type=type(e).__name__) return False + + +async def send_org_invite_email(email: str, accept_link: str, org_name: Optional[str] = None) -> bool: + """发送企业邀请信(Q2-B)。链接指向 mcp landing(web 模式),点击后经 HM 回调建会话并入组。 + + 与 magic-link 同一发信总闸(`MAGIC_LINK_EMAIL_ENABLED`)+ SMTP 通道。任一不满足返回 False、不抛异常。 + """ + if not settings.magic_link_email_enabled: + logger.info("org_invite_email_mock", email=email, + hint="MAGIC_LINK_EMAIL_ENABLED=false → mock,不外发") + return False + if not SMTP_PASSWORD: + logger.error("org_invite_email_smtp_unconfigured", email=email) + return False + + org = (org_name or "").strip() + org_line = f"您被邀请加入组织 **{org}**。" if org else "您收到一封企业邀请。" + try: + msg = MIMEMultipart() + msg["From"] = SMTP_EMAIL + msg["To"] = email + msg["Subject"] = (f"Heicode 企业邀请 - {org}" if org else "Heicode 企业邀请") + body = f""" +您好: + +{org_line} +请点击下面的链接完成登录并接受邀请: + +{accept_link} + +点击后会自动完成身份验证并跳转到接受邀请页面。链接有时效,请尽快点击。 + +如果您并未预期收到此邀请,请忽略此邮件。 + +此邮件由系统自动发送,请勿回复。 + +--- +Heicode 团队 +""" + msg.attach(MIMEText(body, "plain", "utf-8")) + import asyncio + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _send_email_sync, msg) + logger.info("org_invite_email_sent", email=email, org=org or None) + return True + except Exception as e: # noqa: BLE001 + logger.error("org_invite_email_send_failed", email=email, error=str(e), + error_type=type(e).__name__) + return False diff --git a/services/mcp-server/app/routes/auth.py b/services/mcp-server/app/routes/auth.py index 3721982..d6a57b6 100644 --- a/services/mcp-server/app/routes/auth.py +++ b/services/mcp-server/app/routes/auth.py @@ -46,6 +46,7 @@ from app.magic_link import ( store_one_time_code, consume_one_time_code, send_magic_link_email, + send_org_invite_email, check_email_rate_limit, set_email_rate_limit, MAGIC_LINK_TOKEN_TTL_SECONDS, @@ -1423,6 +1424,78 @@ async def provision_user( return SuccessResponse(data={"email": new_user.email, "exists": False}) +# ============================================================ +# Heicode 企业邀请 §Q2-B — 内部端点:发企业邀请信(web 模式 magic-link) +# ============================================================ + + +class _OrgInviteEmailRequest(BaseModel): + """企业邀请信发送请求体(Q2-B 方案B)。""" + email: str + web_callback: str # HM Web 回调页;landing web 模式 302 目标 + accept_url: str # 换到会话后最终跳转(HM /org-accept?token=...) + org_name: Optional[str] = None + expires_in_sec: Optional[int] = None # 邀请 landing token TTL,默认 7 天 + + +@router.post("/internal/org-invite-email", response_model=SuccessResponse) +async def org_invite_email( + payload: _OrgInviteEmailRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """发企业邀请信(Q2-B 方案B)。 + + - 鉴权:`HEICODE_INTERNAL_SERVICE_TOKEN`(同 provision)。 + - 要求 email 为**已 provision 的 user**(未开通 → 404,请先调 /internal/provision)。 + - 生成 magic-link token(绑定 web_callback + accept_url,默认 7 天)→ 发邀请信 → 返回 {request_id, state}。 + - 被邀请人点信 → landing(web 模式) 302 到 `web_callback?code=&state=&redirect=accept_url` + → HM 回调用 code 调 verify 换 token 建会话 → 跳 accept_url 入组。**不动 D-1,不发密码重置码。** + """ + _verify_internal_service_token(request) + + email = (payload.email or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "INVALID_EMAIL", "message": "email 无效"}) + if not payload.web_callback or not payload.accept_url: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "MISSING_CALLBACK", "message": "web_callback 与 accept_url 必填"}) + + result = await db.execute(select(User).where(func.lower(User.email) == email)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "USER_NOT_PROVISIONED", + "message": "该 email 未预开通,请先调 /api/auth/internal/provision"}) + + state = secrets.token_urlsafe(16) + request_id = str(uuid.uuid4()) + ttl = payload.expires_in_sec if (payload.expires_in_sec and payload.expires_in_sec > 0) else 7 * 24 * 3600 + token = generate_magic_link_token() + stored = await store_magic_link_token( + token, user.email, state, + web_callback=payload.web_callback, accept_url=payload.accept_url, ttl=ttl, + ) + if not stored: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "TOKEN_STORE_FAILED", "message": "邀请 token 存储失败"}) + + base = settings.magic_link_public_base_url.rstrip("/") + link = f"{base}/api/auth/magic-link/landing?token={token}&state={state}" + sent = await send_org_invite_email(user.email, link, payload.org_name) + + logger.info("org_invite_email_requested", email=email, org=payload.org_name, + sent=sent, actor="heicode_backend_internal") + return SuccessResponse(data={ + "request_id": request_id, + "state": state, + "email": user.email, + "email_sent": sent, + "expires_in_sec": ttl, + }) + + # ============================================================ # Heicode magic-link 邮箱登录(§2 契约 / §8 D-1·D-2·D-3 / §11 D-4=APIM / §12) # —— 纯新增的并行登录方式:不输密码,邮箱收链接,点链接回跳客户端完成登录。 @@ -1549,7 +1622,17 @@ async def magic_link_landing( logger.error("magic_link_code_store_failed", email=email) return HTMLResponse(content=_LANDING_INVALID_HTML, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) - redirect_url = f"heicode://auth/callback?code={code}&state={state}" + # Q2-B 企业邀请 web 模式:token 带 web_callback → 302 到 HM 回调页(带一次性 code+state + # + 可选 redirect=accept_url);否则维持桌面 heicode:// 深链。 + web_callback = payload.get("web_callback") + if web_callback: + from urllib.parse import quote + redirect_url = f"{web_callback}?code={code}&state={state}" + accept_url = payload.get("accept_url") + if accept_url: + redirect_url += f"&redirect={quote(accept_url, safe='')}" + else: + redirect_url = f"heicode://auth/callback?code={code}&state={state}" return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)