feat(mcp-server): 企业邀请 org-invite-email 端点 + magic-link landing web 模式(Q2-B)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 16:30:21 +08:00
co-authored by Claude Opus 4.8
parent 8a07edecd8
commit 448401427f
3 changed files with 154 additions and 5 deletions
+1
View File
@@ -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 路径
+69 -4
View File
@@ -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
+83
View File
@@ -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,6 +1622,16 @@ 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)
# 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)