forked from xiaohei/taiji-AI-PAD
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:
@@ -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 路径
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user