forked from xiaohei/taiji-AI-PAD
初版 consume_device_code 用裸 redis_client.delete() 且吞异常,在集群 Azure Redis 上删除未生效,导致一个 device_code 换发 token 后仍能在每次 >interval 的轮询继续换发新 token —— 违反 RFC 8628 一次性语义与验收「换一次后再用→拒绝」。 初测二次轮询都在 slow_down 窗口内(<5s)被限流响应遮住,未暴露;>5s 公网 真实轮询复测才暴露。 修复:consume 改用已验证可靠的 _set_keepttl 置 status=consumed(token 端点 签发前硬检查 consumed → expired_token),并 best-effort 删除 device_code + device_user_code 两个 key。即使集群删除失败,状态位硬拦截。 复测(公网 APIM 真实路径,间隔 >5s):首 poll 签发 → 二/三次 poll 均 expired_token,不再重复签发。 镜像 device-code-fix2-20260722-arm64 @sha256:716c2e2d 已部署生产 3/3 Running。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1856 lines
70 KiB
Python
1856 lines
70 KiB
Python
"""
|
||
认证与权限管理路由
|
||
"""
|
||
|
||
from datetime import timedelta
|
||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||
from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse
|
||
import time
|
||
from sqlalchemy import select, and_, func
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.exc import IntegrityError
|
||
import secrets
|
||
import hashlib
|
||
from typing import Optional
|
||
|
||
from database import get_db
|
||
from models import (
|
||
User, Channel, APIKey, Balance, TenantCustomAgentQuota,
|
||
PlatformAgentQuota, TenantModelKey, ResourceAllocation, ModelProvider
|
||
)
|
||
from app.auth import (
|
||
authenticate_user,
|
||
create_access_token,
|
||
create_refresh_token,
|
||
get_password_hash,
|
||
verify_password,
|
||
require_auth,
|
||
)
|
||
from app.schemas import (
|
||
LoginRequest,
|
||
SuccessResponse,
|
||
TokenResponse,
|
||
PasswordChangeRequest,
|
||
ForgotPasswordRequest,
|
||
ResetPasswordRequest,
|
||
APIKeyInfo,
|
||
RegenerateAPIKeyResponse,
|
||
UserCreate,
|
||
)
|
||
from app.email_verification import verify_code, peek_verification_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code
|
||
from app.audit import log_audit_event
|
||
from app.magic_link import (
|
||
generate_magic_link_token,
|
||
generate_one_time_code,
|
||
store_magic_link_token,
|
||
consume_magic_link_token,
|
||
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,
|
||
)
|
||
from app.device_auth import (
|
||
create_device_authorization,
|
||
get_device_state,
|
||
lookup_by_user_code,
|
||
set_device_decision,
|
||
mark_poll,
|
||
consume_device_code,
|
||
DEVICE_CODE_TTL_SECONDS,
|
||
DEVICE_POLL_INTERVAL_SECONDS,
|
||
DEVICE_VERIFICATION_URI,
|
||
)
|
||
from pydantic import BaseModel, EmailStr
|
||
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
|
||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||
from config import settings
|
||
import uuid
|
||
from datetime import datetime
|
||
from sqlalchemy import and_
|
||
import structlog
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||
|
||
|
||
class _LoginRateLimit:
|
||
"""每 IP 维度对登录请求限流(默认 5 次/60s,超出 429)。
|
||
多副本部署下每副本独立计数,可接受。"""
|
||
def __init__(self, max_attempts: int = 5, window_seconds: int = 60):
|
||
self.max_attempts = max_attempts
|
||
self.window_seconds = window_seconds
|
||
self._attempts: dict = {}
|
||
|
||
def _client_ip(self, request) -> str:
|
||
xff = request.headers.get("x-forwarded-for")
|
||
if xff:
|
||
return xff.split(",")[0].strip()
|
||
return request.client.host if request.client else "unknown"
|
||
|
||
async def __call__(self, request: Request):
|
||
import time
|
||
from collections import deque
|
||
ip = self._client_ip(request)
|
||
now = time.time()
|
||
bucket = self._attempts.setdefault(ip, deque())
|
||
while bucket and bucket[0] < now - self.window_seconds:
|
||
bucket.popleft()
|
||
if len(bucket) >= self.max_attempts:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail="登录尝试过于频繁,请稍后再试",
|
||
headers={"Retry-After": str(self.window_seconds)},
|
||
)
|
||
bucket.append(now)
|
||
|
||
|
||
_login_rate_limit = _LoginRateLimit()
|
||
|
||
|
||
def _mask_api_key(key: str) -> str:
|
||
"""隐藏API密钥的中间部分"""
|
||
if len(key) <= 12:
|
||
return key[:4] + "..." + key[-4:]
|
||
return key[:8] + "..." + key[-4:]
|
||
|
||
|
||
@router.post("/login", response_model=SuccessResponse)
|
||
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db), _: None = Depends(_login_rate_limit)):
|
||
"""
|
||
用户/渠道/管理员/供应商登录
|
||
|
||
支持的角色:
|
||
- user: 租户用户
|
||
- channel: 渠道管理员
|
||
- billing_admin: 计费管理员
|
||
- operations_admin: 运营管理员
|
||
- admin: 管理员
|
||
- super_admin: 超级管理员
|
||
- provider: 供应商管理员
|
||
"""
|
||
success = False
|
||
result_user_id: Optional[str] = None
|
||
error_msg: Optional[str] = None
|
||
try:
|
||
# 根据角色查找用户
|
||
if req.role == "channel":
|
||
# 渠道管理员登录
|
||
result = await db.execute(select(Channel).where(Channel.email == req.email))
|
||
entity = result.scalar_one_or_none()
|
||
|
||
if not entity or not verify_password(req.password, entity.password_hash):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="邮箱或密码错误"
|
||
)
|
||
|
||
# 更新最后登录时间
|
||
from datetime import datetime
|
||
entity.last_login_at = datetime.utcnow()
|
||
await db.commit()
|
||
|
||
# 创建JWT token
|
||
token_data = {
|
||
"sub": str(entity.id),
|
||
"email": entity.email,
|
||
"role": "channel_admin",
|
||
"channelId": str(entity.id),
|
||
}
|
||
access_token = create_access_token(data=token_data)
|
||
refresh_token = create_refresh_token(data=token_data)
|
||
|
||
success = True
|
||
result_user_id = str(entity.id)
|
||
return SuccessResponse(
|
||
data={
|
||
"token": access_token,
|
||
"refreshToken": refresh_token,
|
||
"user": {
|
||
"id": str(entity.id),
|
||
"name": entity.name,
|
||
"email": entity.email,
|
||
"role": "channel_admin",
|
||
"channelId": str(entity.id),
|
||
}
|
||
}
|
||
)
|
||
|
||
else:
|
||
# 用户/管理员/供应商登录
|
||
result = await db.execute(select(User).where(User.email == req.email))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if not user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="邮箱或密码错误"
|
||
)
|
||
|
||
# 验证密码(兼容两种密码字段)
|
||
password_hash = user.password_hash or user.hashed_password
|
||
if not password_hash or not verify_password(req.password, password_hash):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="邮箱或密码错误"
|
||
)
|
||
|
||
# 验证用户状态
|
||
if hasattr(user, 'status') and user.status == "inactive":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="账户已被禁用"
|
||
)
|
||
|
||
# 验证角色
|
||
user_role = user.role
|
||
|
||
# 角色验证逻辑
|
||
valid_roles = {
|
||
"super_admin": ["super_admin"],
|
||
"admin": ["admin", "super_admin"],
|
||
"billing_admin": ["billing_admin", "admin", "super_admin"],
|
||
"operations_admin": ["operations_admin", "admin", "super_admin"],
|
||
"user": ["user"],
|
||
"provider": ["provider_admin"],
|
||
}
|
||
|
||
allowed_roles = valid_roles.get(req.role, [])
|
||
if user_role not in allowed_roles:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=f"权限不足,当前角色: {user_role}"
|
||
)
|
||
|
||
# 更新最后登录时间
|
||
from datetime import datetime
|
||
user.last_login_at = datetime.utcnow()
|
||
await db.commit()
|
||
|
||
# 创建JWT token
|
||
token_data = {
|
||
"sub": str(user.id),
|
||
"email": user.email,
|
||
"role": user_role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
}
|
||
access_token = create_access_token(data=token_data)
|
||
refresh_token = create_refresh_token(data=token_data)
|
||
|
||
success = True
|
||
result_user_id = str(user.id)
|
||
return SuccessResponse(
|
||
data={
|
||
"token": access_token,
|
||
"refreshToken": refresh_token,
|
||
"user": {
|
||
"id": str(user.id),
|
||
"name": user.name or user.full_name,
|
||
"email": user.email,
|
||
"role": user_role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
}
|
||
}
|
||
)
|
||
except HTTPException as e:
|
||
error_msg = e.detail if isinstance(e.detail, str) else str(e.detail)
|
||
raise
|
||
finally:
|
||
try:
|
||
await log_audit_event(
|
||
action="auth.login",
|
||
resource_type="user",
|
||
resource_id=req.email,
|
||
user_id=result_user_id,
|
||
success=success,
|
||
details={"role": req.role},
|
||
error_message=error_msg,
|
||
request=request,
|
||
db=db,
|
||
)
|
||
except Exception as audit_exc:
|
||
logger.warning("auth_login_audit_failed", error=str(audit_exc))
|
||
|
||
|
||
@router.get("/me", response_model=SuccessResponse)
|
||
async def get_current_user(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
获取当前登录用户信息
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if user is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="用户不存在"
|
||
)
|
||
|
||
if getattr(user, "status", None) == "inactive":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="账户已被禁用"
|
||
)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"id": str(user.id),
|
||
"email": user.email,
|
||
"name": user.name or user.full_name,
|
||
"role": user.role,
|
||
"channelId": str(user.channel_id) if user.channel_id is not None else None,
|
||
"status": user.status,
|
||
"subscriptionTier": getattr(user, "subscription_tier", None),
|
||
"lastLoginAt": user.last_login_at.isoformat() if user.last_login_at is not None else None,
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/logout", response_model=SuccessResponse)
|
||
async def logout(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
用户登出
|
||
|
||
将当前Token加入黑名单
|
||
"""
|
||
from datetime import datetime, timedelta
|
||
from app.token_blacklist import add_token_to_blacklist
|
||
import uuid as uuid_module
|
||
|
||
user_id = principal.get("user_id")
|
||
claims = principal.get("claims", {})
|
||
|
||
# 确保 user_id 是有效的 UUID 字符串
|
||
if user_id:
|
||
try:
|
||
# 验证并转换为标准 UUID 字符串格式
|
||
user_id = str(uuid_module.UUID(str(user_id)))
|
||
except (ValueError, TypeError):
|
||
# 如果无法转换,使用 claims 中的 sub
|
||
sub = claims.get("sub")
|
||
if sub:
|
||
try:
|
||
user_id = str(uuid_module.UUID(str(sub)))
|
||
except (ValueError, TypeError):
|
||
user_id = None
|
||
|
||
if not user_id:
|
||
# 如果无法获取有效的 user_id,直接返回成功(前端会清除本地 token)
|
||
return SuccessResponse(message="登出成功")
|
||
|
||
# 获取Token的过期时间(从claims中提取)
|
||
exp = claims.get("exp")
|
||
if exp:
|
||
expires_at = datetime.utcfromtimestamp(exp)
|
||
else:
|
||
# 默认7天后过期
|
||
expires_at = datetime.utcnow() + timedelta(days=7)
|
||
|
||
# 生成token的唯一标识(使用sub + 时间戳)
|
||
token_jti = f"logout_{user_id}_{datetime.utcnow().timestamp()}"
|
||
|
||
try:
|
||
# 添加到黑名单
|
||
await add_token_to_blacklist(
|
||
token_jti=token_jti,
|
||
user_id=user_id,
|
||
expires_at=expires_at,
|
||
reason="logout",
|
||
db=db,
|
||
)
|
||
except Exception as e:
|
||
# 即使黑名单添加失败,也返回成功(前端会清除本地 token)
|
||
import structlog
|
||
logger = structlog.get_logger(__name__)
|
||
logger.warning("failed_to_add_token_to_blacklist", error=str(e), user_id=user_id)
|
||
|
||
return SuccessResponse(message="登出成功")
|
||
|
||
|
||
@router.post("/refresh", response_model=SuccessResponse)
|
||
async def refresh_token_endpoint(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
刷新访问令牌
|
||
|
||
使用 Refresh Token 获取新的 Access Token
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
claims = principal.get("claims", {})
|
||
|
||
# 验证是否为 refresh token
|
||
if claims.get("type") != "refresh":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="必须使用 refresh token 调用此接口"
|
||
)
|
||
|
||
# 查询用户信息
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if not user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="用户不存在"
|
||
)
|
||
|
||
# 生成新的 access token 和 refresh token
|
||
token_data = {
|
||
"sub": str(user.id),
|
||
"email": user.email,
|
||
"role": user.role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
}
|
||
new_access_token = create_access_token(data=token_data)
|
||
new_refresh_token = create_refresh_token(data=token_data)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"token": new_access_token,
|
||
"refreshToken": new_refresh_token,
|
||
}
|
||
)
|
||
|
||
|
||
@router.put("/password", response_model=SuccessResponse)
|
||
async def change_password(
|
||
req: PasswordChangeRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
修改密码
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
|
||
# 查询用户
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if not user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="用户不存在"
|
||
)
|
||
|
||
# 验证旧密码
|
||
password_hash = user.password_hash or user.hashed_password
|
||
if not verify_password(req.old_password, password_hash):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="旧密码错误"
|
||
)
|
||
|
||
# 更新密码
|
||
new_hash = get_password_hash(req.new_password)
|
||
user.password_hash = new_hash
|
||
user.hashed_password = new_hash # 兼容旧字段
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(message="密码修改成功")
|
||
|
||
|
||
@router.get("/keys/info", response_model=SuccessResponse)
|
||
async def get_api_key_info(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取服务终结点和API密钥信息
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
|
||
# 查询用户的API密钥
|
||
result = await db.execute(
|
||
select(APIKey)
|
||
.where(APIKey.user_id == user_id)
|
||
.where(APIKey.is_active == True)
|
||
.order_by(APIKey.created_at.desc())
|
||
)
|
||
api_key = result.scalar_one_or_none()
|
||
|
||
if not api_key:
|
||
# 创建新的API密钥
|
||
raw_key = f"sk-{secrets.token_urlsafe(32)}"
|
||
key_hash = get_password_hash(raw_key)
|
||
|
||
new_key = APIKey(
|
||
user_id=user_id,
|
||
api_key_hash=key_hash,
|
||
api_key_prefix=raw_key[:8],
|
||
name="默认密钥",
|
||
key_hash=key_hash,
|
||
prefix=raw_key[:8],
|
||
is_active=True,
|
||
)
|
||
db.add(new_key)
|
||
await db.commit()
|
||
await db.refresh(new_key)
|
||
|
||
api_key = new_key
|
||
masked_key = _mask_api_key(raw_key)
|
||
else:
|
||
# 隐藏现有密钥
|
||
masked_key = api_key.api_key_prefix + "..." + "xxxx"
|
||
|
||
endpoint = f"https://api.taiji-ai.com/v1" # 可以从配置读取
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"endpoint": endpoint,
|
||
"apiKey": masked_key,
|
||
"createdAt": api_key.created_at.isoformat(),
|
||
"lastUsed": api_key.last_used.isoformat() if api_key.last_used else None,
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/keys/regenerate", response_model=SuccessResponse)
|
||
async def regenerate_api_key(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
重新生成API密钥
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
|
||
# 禁用所有旧密钥
|
||
result = await db.execute(
|
||
select(APIKey).where(APIKey.user_id == user_id)
|
||
)
|
||
old_keys = result.scalars().all()
|
||
|
||
for key in old_keys:
|
||
key.is_active = False
|
||
|
||
# 生成新密钥
|
||
raw_key = f"sk-{secrets.token_urlsafe(32)}"
|
||
key_hash = get_password_hash(raw_key)
|
||
|
||
new_key = APIKey(
|
||
user_id=user_id,
|
||
api_key_hash=key_hash,
|
||
api_key_prefix=raw_key[:8],
|
||
name="重新生成的密钥",
|
||
key_hash=key_hash,
|
||
prefix=raw_key[:8],
|
||
is_active=True,
|
||
)
|
||
db.add(new_key)
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"apiKey": raw_key, # 完整显示新密钥
|
||
"message": "旧密钥已失效",
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/keys", response_model=SuccessResponse)
|
||
async def list_api_keys(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取用户的 API 密钥列表
|
||
|
||
返回所有密钥的信息(不包含完整密钥内容,仅显示前缀)
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
|
||
result = await db.execute(
|
||
select(APIKey)
|
||
.where(APIKey.user_id == user_id)
|
||
.order_by(APIKey.created_at.desc())
|
||
)
|
||
keys = result.scalars().all()
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"keys": [
|
||
{
|
||
"id": str(key.id),
|
||
"name": key.name or "默认密钥",
|
||
"prefix": key.api_key_prefix + "...",
|
||
"isActive": key.is_active,
|
||
"createdAt": key.created_at.isoformat(),
|
||
"lastUsed": key.last_used.isoformat() if key.last_used else None,
|
||
"expiresAt": key.expires_at.isoformat() if key.expires_at else None,
|
||
"totalRequests": key.total_requests or 0,
|
||
}
|
||
for key in keys
|
||
],
|
||
"total": len(keys)
|
||
},
|
||
message=f"共 {len(keys)} 个 API 密钥"
|
||
)
|
||
|
||
|
||
@router.post("/keys", response_model=SuccessResponse)
|
||
async def create_api_key(
|
||
name: str = Query(default="API Key", description="密钥名称"),
|
||
expires_in_days: Optional[int] = Query(default=None, description="过期天数,不填则永不过期"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
创建新的 API 密钥
|
||
|
||
注意:密钥只在创建时显示一次,请妥善保管
|
||
|
||
参数:
|
||
- name: 密钥名称,用于标识不同用途的密钥
|
||
- expires_in_days: 过期天数,不填则永不过期
|
||
|
||
返回:
|
||
- key: 完整的 API 密钥(只在创建时返回一次)
|
||
- id: 密钥 ID,用于删除操作
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
|
||
# 生成新密钥
|
||
raw_key = f"sk-{secrets.token_urlsafe(32)}"
|
||
key_hash = get_password_hash(raw_key)
|
||
|
||
expires_at = None
|
||
if expires_in_days:
|
||
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
|
||
|
||
new_key = APIKey(
|
||
user_id=user_id,
|
||
api_key_hash=key_hash,
|
||
api_key_prefix=raw_key[:8],
|
||
name=name,
|
||
key_hash=key_hash,
|
||
prefix=raw_key[:8],
|
||
is_active=True,
|
||
expires_at=expires_at,
|
||
total_requests=0,
|
||
)
|
||
db.add(new_key)
|
||
await db.commit()
|
||
await db.refresh(new_key)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"id": str(new_key.id),
|
||
"name": name,
|
||
"key": raw_key, # 只在创建时返回完整密钥
|
||
"prefix": raw_key[:8] + "...",
|
||
"expiresAt": expires_at.isoformat() if expires_at else None,
|
||
"createdAt": new_key.created_at.isoformat(),
|
||
},
|
||
message="API 密钥创建成功,请妥善保管,密钥只显示一次"
|
||
)
|
||
|
||
|
||
@router.delete("/keys/{key_id}", response_model=SuccessResponse)
|
||
async def delete_api_key(
|
||
key_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
删除 API 密钥
|
||
|
||
参数:
|
||
- key_id: 密钥 ID(从列表接口或创建接口获取)
|
||
|
||
注意:删除后密钥立即失效,无法恢复
|
||
"""
|
||
user_id = principal.get("user_id")
|
||
|
||
try:
|
||
key_uuid = uuid.UUID(key_id)
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="无效的密钥 ID")
|
||
|
||
result = await db.execute(
|
||
select(APIKey)
|
||
.where(APIKey.id == key_uuid)
|
||
.where(APIKey.user_id == user_id)
|
||
)
|
||
key = result.scalar_one_or_none()
|
||
|
||
if not key:
|
||
raise HTTPException(status_code=404, detail="密钥不存在或无权删除")
|
||
|
||
await db.delete(key)
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={"id": key_id, "name": key.name},
|
||
message=f"API 密钥 '{key.name}' 已删除"
|
||
)
|
||
|
||
|
||
@router.post("/register/send-code", response_model=SuccessResponse)
|
||
async def send_verification_code_endpoint(
|
||
email: str = Query(..., description="邮箱地址"),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
发送邮箱验证码
|
||
|
||
在用户注册前,先调用此接口发送验证码到邮箱
|
||
|
||
频率限制:同一邮箱60秒内只能发送一次
|
||
"""
|
||
# Q3 双方统一小写:归一化后下游(限流键/存在校验/验证码 Redis key)与 register 一致
|
||
email = (email or "").strip().lower()
|
||
|
||
# 1. 检查发送频率限制
|
||
can_send, remaining_seconds = await check_rate_limit(email)
|
||
if not can_send:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail=f"请等待{remaining_seconds}秒后再重新发送验证码"
|
||
)
|
||
|
||
# 2. 检查邮箱是否已存在
|
||
result = await db.execute(select(User).where(User.email == email))
|
||
existing_user = result.scalar_one_or_none()
|
||
|
||
if existing_user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该邮箱已被注册"
|
||
)
|
||
|
||
# 3. 发送验证码
|
||
code = await send_and_store_verification_code(email)
|
||
if not code:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="验证码发送失败,请稍后重试"
|
||
)
|
||
|
||
return SuccessResponse(
|
||
message="验证码已发送到您的邮箱,请查收"
|
||
)
|
||
|
||
|
||
@router.post("/register", response_model=SuccessResponse)
|
||
async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||
"""
|
||
用户注册接口
|
||
|
||
允许用户自由注册,创建普通用户账户
|
||
注册成功后自动分配默认资源:
|
||
- 分配到 taiji 渠道 (channelId: b415e70b-8d37-481c-b229-bc3b7871607b)
|
||
- 自定义 agent 配额:2 CPU, 2 GB 内存
|
||
- 平台 agent 各1个
|
||
- 供应商所有模型
|
||
- 余额:20元
|
||
"""
|
||
# Q3 双方统一小写:入口归一化 email,下游(存在校验/用户名兜底/验证码 Redis key/落库)全部一致
|
||
req.email = (req.email or "").strip().lower()
|
||
|
||
# 1. 先检查邮箱是否已存在(不消耗验证码;大小写不敏感,防历史混合大小写重复建号)
|
||
result = await db.execute(select(User).where(func.lower(User.email) == req.email))
|
||
existing_user = result.scalar_one_or_none()
|
||
|
||
if existing_user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该邮箱已被注册"
|
||
)
|
||
|
||
# 2. 检查用户名是否已存在
|
||
# 注意:line 778 处会用 `req.username or req.email.split("@")[0]` 兜底,
|
||
# 所以预检必须按真实写入的 username 来查,否则 fallback 出来的 alice
|
||
# 没经预检,在 line 797 flush 才暴露 IntegrityError。
|
||
effective_username = (req.username or "").strip() or req.email.split("@")[0]
|
||
result = await db.execute(select(User).where(User.username == effective_username))
|
||
existing_username = result.scalar_one_or_none()
|
||
if existing_username:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该用户名已被使用"
|
||
)
|
||
|
||
# 3. 验证邮箱验证码(先 peek 不消费 —— 等 DB commit + 外部副作用全部成功
|
||
# 后再调 verify_code 真消费。否则后续任何步骤失败,用户的验证码就被
|
||
# 白白烧掉了,必须重新发码。
|
||
is_valid = await peek_verification_code(req.email, req.verification_code)
|
||
if not is_valid:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="验证码错误或已过期"
|
||
)
|
||
|
||
# taiji 渠道 ID
|
||
TAIJI_CHANNEL_ID = uuid.UUID("b415e70b-8d37-481c-b229-bc3b7871607b")
|
||
|
||
# 验证渠道存在
|
||
channel_result = await db.execute(
|
||
select(Channel).where(Channel.id == TAIJI_CHANNEL_ID)
|
||
)
|
||
channel = channel_result.scalar_one_or_none()
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="渠道不存在,请联系管理员"
|
||
)
|
||
|
||
# 创建新用户
|
||
password_hash = get_password_hash(req.password)
|
||
# 用同一套 effective_username 写入 —— line 745 的预检和这里的写入必须 100%
|
||
# 匹配,否则带空格的 username(如 "alice ")会预检通过但写入不一致。
|
||
username = effective_username
|
||
name = req.full_name or username
|
||
|
||
new_user = User(
|
||
name=name,
|
||
email=req.email,
|
||
password_hash=password_hash,
|
||
hashed_password=password_hash, # 兼容字段
|
||
username=username,
|
||
full_name=req.full_name or name,
|
||
role="user", # 默认角色为普通用户
|
||
channel_id=TAIJI_CHANNEL_ID, # 分配到 taiji 渠道
|
||
status="active",
|
||
is_active=True,
|
||
is_admin=False,
|
||
credit_limit=0,
|
||
total_eu_consumed=0,
|
||
)
|
||
|
||
try:
|
||
db.add(new_user)
|
||
await db.flush() # 获取 user.id,这里会触发唯一约束检查
|
||
except IntegrityError as e:
|
||
await db.rollback()
|
||
error_str = str(e.orig) if e.orig else str(e)
|
||
if "email" in error_str.lower() or "users_email_key" in error_str.lower():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该邮箱已被注册"
|
||
)
|
||
elif "username" in error_str.lower() or "users_username_key" in error_str.lower():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该用户名已被使用"
|
||
)
|
||
else:
|
||
logger.error(f"注册时数据库约束冲突: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="注册信息与现有用户冲突,请更换邮箱或用户名"
|
||
)
|
||
|
||
user_id = new_user.id
|
||
|
||
# 跟踪本次注册在 LiteLLM 远端已经创建的 key —— commit 失败时回扫 delete,
|
||
# 避免远端泄漏 orphan key。注意:generate_key 是 fire-and-forget 网络副作
|
||
# 用,DB rollback 不能撤销它,必须显式 delete。
|
||
created_litellm_keys: list[str] = []
|
||
try:
|
||
# 1. 创建余额记录,初始余额 20 元
|
||
balance = Balance(
|
||
user_id=user_id,
|
||
eu_balance=20.0
|
||
)
|
||
db.add(balance)
|
||
|
||
# 2. 分配自定义 Agent 配额:2 CPU, 2 GB 内存
|
||
custom_agent_quota = TenantCustomAgentQuota(
|
||
tenant_id=user_id,
|
||
cpu_quota=2.0,
|
||
memory_quota=2.0,
|
||
cpu_used=0.0,
|
||
memory_used=0.0,
|
||
agent_count=0
|
||
)
|
||
db.add(custom_agent_quota)
|
||
|
||
# 3. 获取所有平台 Agent 模板,为每个模板分配 1 个配额
|
||
try:
|
||
agent_manager_client = get_agent_manager_client()
|
||
platform_templates = await agent_manager_client.list_platform_templates()
|
||
|
||
for template in platform_templates:
|
||
template_name = template.template
|
||
|
||
# 获取渠道的配额记录(需要同步更新 pod_used)
|
||
channel_quota_result = await db.execute(
|
||
select(PlatformAgentQuota)
|
||
.where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == TAIJI_CHANNEL_ID,
|
||
PlatformAgentQuota.target_type == "channel",
|
||
PlatformAgentQuota.template_name == template_name
|
||
)
|
||
)
|
||
.with_for_update() # 行锁
|
||
)
|
||
channel_quota = channel_quota_result.scalar_one_or_none()
|
||
|
||
# 如果渠道没有该模板的配额,跳过
|
||
if not channel_quota:
|
||
logger.warning(f"渠道没有平台 Agent '{template_name}' 的配额,跳过分配")
|
||
continue
|
||
|
||
# 检查渠道剩余配额是否足够
|
||
remaining = channel_quota.pod_quota - (channel_quota.pod_used or 0)
|
||
if remaining < 1:
|
||
logger.warning(f"渠道平台 Agent '{template_name}' 配额不足,跳过分配")
|
||
continue
|
||
|
||
# 创建平台 Agent 配额记录
|
||
platform_quota = PlatformAgentQuota(
|
||
target_id=user_id,
|
||
target_type="tenant",
|
||
template_name=template_name,
|
||
pod_quota=1, # 每个平台 agent 分配 1 个配额
|
||
pod_used=0,
|
||
allocated_by=None, # 系统自动分配
|
||
allocated_at=datetime.utcnow()
|
||
)
|
||
db.add(platform_quota)
|
||
|
||
# Bug 修复:更新渠道的 pod_used(分配给租户的配额视为渠道已使用的配额)
|
||
channel_quota.pod_used = (channel_quota.pod_used or 0) + 1
|
||
|
||
# 同时创建 ResourceAllocation 记录(兼容旧逻辑)
|
||
allocation = ResourceAllocation(
|
||
target_id=user_id,
|
||
target_type="tenant",
|
||
resource_type="agent",
|
||
resource_id=template_name,
|
||
quantity=1
|
||
)
|
||
db.add(allocation)
|
||
except AgentManagerError as e:
|
||
# AgentManager 不可达是已知运维态,平台 Agent 分配跳过,注册主流程
|
||
# 继续;其他异常(SQLAlchemy / 编程错误等)让外层 try 兜底回滚。
|
||
logger.warning(f"获取平台 Agent 模板失败,跳过平台 Agent 分配: {e}")
|
||
|
||
# 4. 分配所有供应商模型
|
||
providers_result = await db.execute(
|
||
select(ModelProvider).where(ModelProvider.is_active == True)
|
||
)
|
||
providers = providers_result.scalars().all()
|
||
|
||
if channel.litellm_team_id:
|
||
litellm_client = get_litellm_client()
|
||
|
||
# 跨 provider 去重:DB 唯一约束是 (tenant_id, model_name),
|
||
# 如果两个 ModelProvider 行的 supported_models 有重叠(例如都包含
|
||
# taiji/gpt-4o-mini),不去重会触发 uq_tenant_model 导致整个事务
|
||
# 失败 → 注册 500。先以 model_name 为键去重,第一个看到的 provider 胜出。
|
||
seen_models: set[str] = set()
|
||
for provider in providers:
|
||
# 为每个供应商的每个模型创建 TenantModelKey
|
||
for model_name in provider.supported_models:
|
||
if model_name in seen_models:
|
||
logger.debug(
|
||
f"模型 {model_name} 已在其他 provider 处理过,跳过"
|
||
)
|
||
continue
|
||
seen_models.add(model_name)
|
||
try:
|
||
# 在 LiteLLM 中创建 Key
|
||
key = await litellm_client.generate_key(
|
||
team_id=channel.litellm_team_id,
|
||
models=[model_name],
|
||
rpm_limit=provider.rpm or 60,
|
||
tpm_limit=provider.tpm or 10000,
|
||
max_budget=500.0, # 默认预算
|
||
budget_duration="monthly",
|
||
key_name=f"tenant-{user_id}-{model_name}",
|
||
metadata={
|
||
"tenant_id": str(user_id),
|
||
"tenant_name": name,
|
||
"channel_id": str(TAIJI_CHANNEL_ID),
|
||
"channel_name": channel.name,
|
||
"model": model_name,
|
||
}
|
||
)
|
||
|
||
# 远端 key 创建成功 —— 立刻登记到 created_litellm_keys,
|
||
# 任何后续失败(DB add/flush/commit)都能 best-effort
|
||
# 删除掉远端 orphan。
|
||
created_litellm_keys.append(key.key)
|
||
|
||
# 加密存储 Key
|
||
encrypted_key = litellm_client.encrypt_key(key.key)
|
||
|
||
# 保存到数据库
|
||
tenant_key = TenantModelKey(
|
||
tenant_id=user_id,
|
||
channel_id=TAIJI_CHANNEL_ID,
|
||
model_name=model_name,
|
||
litellm_key_id=key.key,
|
||
litellm_key_hash=encrypted_key,
|
||
rpm_limit=provider.rpm or 60,
|
||
tpm_limit=provider.tpm or 10000,
|
||
max_budget=500.0,
|
||
budget_duration="monthly",
|
||
status="active",
|
||
)
|
||
db.add(tenant_key)
|
||
|
||
# 同时记录到 ResourceAllocation
|
||
model_allocation = ResourceAllocation(
|
||
target_id=user_id,
|
||
target_type="tenant",
|
||
resource_type="model",
|
||
resource_id=str(provider.id),
|
||
rpm=provider.rpm or 60,
|
||
tpm=provider.tpm or 10000,
|
||
)
|
||
db.add(model_allocation)
|
||
except LiteLLMClientError as e:
|
||
# LiteLLM 远端 API 错误:单模型分配失败可接受,跳过该
|
||
# 模型继续。注意 generate_key 已在 created_litellm_keys
|
||
# 追加之前抛出,所以这条路径不会有 orphan。
|
||
logger.warning(f"为模型 {model_name} 创建 LiteLLM Key 失败: {e}")
|
||
# 继续处理其他模型
|
||
# SQLAlchemy / 编程错误等其他异常:故意不 catch —— 让外层 try
|
||
# 接住执行 rollback + LiteLLM orphan 清理,避免静默返回 200
|
||
# 但只分到一半 key 的「假成功」。
|
||
else:
|
||
logger.warning(f"渠道 {TAIJI_CHANNEL_ID} 未配置 LiteLLM team,跳过模型分配")
|
||
|
||
# 提交所有更改
|
||
await db.commit()
|
||
await db.refresh(new_user)
|
||
|
||
logger.info(
|
||
f"用户注册成功并分配默认资源",
|
||
user_id=str(user_id),
|
||
email=req.email,
|
||
channel_id=str(TAIJI_CHANNEL_ID),
|
||
litellm_key_count=len(created_litellm_keys),
|
||
)
|
||
|
||
except Exception as e:
|
||
# 1. DB 回滚(new_user / balance / quota / TenantModelKey 全部撤销)
|
||
await db.rollback()
|
||
|
||
# 2. 关键:DB 回滚撤不掉 LiteLLM 远端已创建的 key —— 必须显式逐个 delete。
|
||
# best-effort:每个 delete 都 try/except,避免一个失败阻塞剩下的清理。
|
||
if created_litellm_keys:
|
||
logger.warning(
|
||
"注册失败 —— 开始清理 LiteLLM 远端 orphan key",
|
||
email=req.email,
|
||
orphan_count=len(created_litellm_keys),
|
||
)
|
||
try:
|
||
client = get_litellm_client()
|
||
for k in created_litellm_keys:
|
||
try:
|
||
await client.delete_key(k)
|
||
except Exception as del_err:
|
||
logger.error(
|
||
"清理 orphan LiteLLM key 失败(需人工跟进)",
|
||
key_prefix=k[:12] if k else None,
|
||
email=req.email,
|
||
error=str(del_err),
|
||
)
|
||
except Exception as cleanup_err:
|
||
# 连 LiteLLM client 都拿不到:把 orphan key 写到日志里,运维兜底
|
||
logger.error(
|
||
"无法获取 LiteLLM client 清理 orphan key",
|
||
email=req.email,
|
||
orphan_keys_first_chars=[k[:12] for k in created_litellm_keys],
|
||
error=str(cleanup_err),
|
||
)
|
||
|
||
# 3. 注意:绝不能把 str(e) 回给客户端 —— SQLAlchemy IntegrityError 的 str
|
||
# 会包含完整 SQL + 全部 parameters,参数里有 LiteLLM 明文 key
|
||
# (litellm_key_id 列)。日志里完整记录便于排障,响应只给通用错误码。
|
||
logger.error("用户注册失败", email=req.email, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail={"code": "REGISTER_FAILED",
|
||
"message": "注册失败,请稍后重试或联系管理员"},
|
||
)
|
||
|
||
# DB commit 已成功 —— 最后才真正消费验证码。这一步即使失败也不再回滚
|
||
# (用户已经注册成功,重复消费没意义;Redis 里的过期码不会被复用,因为
|
||
# 同 email 第二次 register 会在 line 737 的 existing_user 检查处 400)。
|
||
#
|
||
# 用 asyncio.shield 保护 verify_code 不被 client disconnect / task cancel
|
||
# 半道杀掉 —— 否则 CancelledError 是 BaseException 直接穿透 except
|
||
# Exception,Redis 里残留 10min TTL 的码(不影响安全,但占资源)。
|
||
import asyncio # local import 避免 module-level 顺序问题
|
||
try:
|
||
await asyncio.shield(verify_code(req.email, req.verification_code))
|
||
except asyncio.CancelledError:
|
||
# request 已经被取消,shield 内部的 verify_code 还会跑完。重新抛出,
|
||
# 让 FastAPI 走正常取消流程。
|
||
raise
|
||
except Exception as e:
|
||
logger.warning("注册成功后消费验证码失败(不影响注册结果)",
|
||
email=req.email, error=str(e))
|
||
|
||
# 创建JWT token,自动登录
|
||
token_data = {
|
||
"sub": str(new_user.id),
|
||
"email": new_user.email,
|
||
"role": new_user.role,
|
||
"user_id": str(new_user.id),
|
||
"channelId": str(TAIJI_CHANNEL_ID),
|
||
}
|
||
|
||
# 创建 Access Token(短期有效)和 Refresh Token(长期有效)
|
||
access_token = create_access_token(data=token_data)
|
||
refresh_token = create_refresh_token(data=token_data)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"token": access_token,
|
||
"refreshToken": refresh_token,
|
||
"user": {
|
||
"id": str(new_user.id),
|
||
"name": new_user.name,
|
||
"email": new_user.email,
|
||
"username": new_user.username,
|
||
"role": new_user.role,
|
||
"channelId": str(TAIJI_CHANNEL_ID),
|
||
}
|
||
},
|
||
message="注册成功"
|
||
)
|
||
|
||
|
||
@router.post("/forgot-password/send-code", response_model=SuccessResponse)
|
||
async def forgot_password_send_code(
|
||
req: ForgotPasswordRequest,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
忘记密码 - 发送验证码
|
||
|
||
向用户注册邮箱发送密码重置验证码。
|
||
|
||
频率限制:同一邮箱60秒内只能发送一次
|
||
|
||
请求体:
|
||
{
|
||
"email": "user@example.com"
|
||
}
|
||
|
||
响应:
|
||
- 成功:返回验证码已发送的消息
|
||
- 失败:邮箱不存在、发送频率限制等
|
||
"""
|
||
# 1. 检查发送频率限制
|
||
can_send, remaining_seconds = await check_rate_limit(req.email)
|
||
if not can_send:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail=f"请等待{remaining_seconds}秒后再重新发送验证码"
|
||
)
|
||
|
||
# 2. 检查邮箱是否存在(必须是已注册用户)
|
||
result = await db.execute(select(User).where(User.email == req.email))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if not user:
|
||
# 为了安全,不直接告知邮箱不存在,但记录日志
|
||
logger.warning("忘记密码请求:邮箱不存在", email=req.email)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="该邮箱未注册"
|
||
)
|
||
|
||
# 3. 发送密码重置验证码(使用专门的密码重置邮件模板)
|
||
code = await send_password_reset_code(req.email)
|
||
if not code:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="验证码发送失败,请稍后重试"
|
||
)
|
||
|
||
logger.info("忘记密码验证码已发送", email=req.email, user_id=str(user.id))
|
||
|
||
return SuccessResponse(
|
||
message="验证码已发送到您的邮箱,请查收"
|
||
)
|
||
|
||
|
||
@router.post("/forgot-password/reset", response_model=SuccessResponse)
|
||
async def forgot_password_reset(
|
||
req: ResetPasswordRequest,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
忘记密码 - 重置密码
|
||
|
||
验证邮箱验证码并重置密码。
|
||
|
||
请求体:
|
||
{
|
||
"email": "user@example.com",
|
||
"verification_code": "123456",
|
||
"new_password": "newPassword123"
|
||
}
|
||
|
||
响应:
|
||
- 成功:密码重置成功
|
||
- 失败:验证码错误、邮箱不存在等
|
||
"""
|
||
# 1. 检查邮箱是否存在
|
||
result = await db.execute(select(User).where(User.email == req.email))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if not user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="该邮箱未注册"
|
||
)
|
||
|
||
# 2. 验证验证码
|
||
is_valid = await verify_code(req.email, req.verification_code)
|
||
if not is_valid:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="验证码错误或已过期"
|
||
)
|
||
|
||
# 3. 更新密码
|
||
new_hash = get_password_hash(req.new_password)
|
||
user.password_hash = new_hash
|
||
user.hashed_password = new_hash # 兼容旧字段
|
||
|
||
await db.commit()
|
||
|
||
logger.info("密码重置成功", email=req.email, user_id=str(user.id))
|
||
|
||
return SuccessResponse(
|
||
message="密码重置成功,请使用新密码登录"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Heicode §7.8.1 — 内部端点:Heicode 后端 syncLocalUserFromAgnet 调用
|
||
# 标记用户的 billing_provider('newapi' / 'litellm')
|
||
# ============================================================
|
||
|
||
|
||
def _verify_internal_service_token(request: Request) -> None:
|
||
"""校验内部服务密钥(不是用户 JWT)。失败 401/403/503。"""
|
||
expected = (settings.heicode_internal_service_token or "").strip()
|
||
if not expected:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail={
|
||
"code": "INTERNAL_SERVICE_AUTH_NOT_CONFIGURED",
|
||
"message": "HEICODE_INTERNAL_SERVICE_TOKEN 未配置",
|
||
},
|
||
)
|
||
auth = (request.headers.get("Authorization") or "").strip()
|
||
if not auth.lower().startswith("bearer "):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Authorization: Bearer <internal service token> required",
|
||
)
|
||
presented = auth[7:].strip()
|
||
# 常量时间比较防 timing attack
|
||
if not secrets.compare_digest(presented, expected):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail={"code": "INVALID_INTERNAL_SERVICE_TOKEN",
|
||
"message": "internal service token mismatch"},
|
||
)
|
||
|
||
|
||
class _BillingProviderUpdate(BaseModel):
|
||
"""内部端点请求体。email 或 user_id 二选一。"""
|
||
email: Optional[str] = None
|
||
user_id: Optional[str] = None
|
||
billing_provider: str # 'newapi' | 'litellm'
|
||
|
||
|
||
@router.put("/internal/billing-provider", response_model=SuccessResponse)
|
||
async def set_billing_provider(
|
||
payload: _BillingProviderUpdate,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Heicode §7.8.1 内部端点:标记用户的 billing_provider。
|
||
|
||
**不**用 user JWT 鉴权,用 HEICODE_INTERNAL_SERVICE_TOKEN 共享密钥(避免
|
||
给 Heicode 后端发用户 JWT)。
|
||
|
||
用途:Heicode 后端 syncLocalUserFromAgnet 同步出新用户时,调本接口把
|
||
User.billing_provider 设为 'newapi'。
|
||
"""
|
||
_verify_internal_service_token(request)
|
||
|
||
if payload.billing_provider not in ("newapi", "litellm"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"code": "INVALID_BILLING_PROVIDER",
|
||
"message": "billing_provider 必须是 'newapi' 或 'litellm'"},
|
||
)
|
||
if not payload.email and not payload.user_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"code": "MISSING_USER_REF",
|
||
"message": "email 或 user_id 二选一必填"},
|
||
)
|
||
|
||
user = None
|
||
if payload.user_id:
|
||
try:
|
||
uid = uuid.UUID(payload.user_id)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
|
||
user = await db.get(User, uid)
|
||
else:
|
||
result = await db.execute(select(User).where(User.email == payload.email))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if user is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"code": "USER_NOT_FOUND",
|
||
"message": f"找不到用户 (email={payload.email}, user_id={payload.user_id})"},
|
||
)
|
||
|
||
old = user.billing_provider
|
||
user.billing_provider = payload.billing_provider
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
|
||
logger.info(
|
||
"billing_provider_updated",
|
||
user_id=str(user.id),
|
||
email=user.email,
|
||
old_provider=old,
|
||
new_provider=user.billing_provider,
|
||
actor="heicode_backend_internal",
|
||
)
|
||
|
||
return SuccessResponse(data={
|
||
"user_id": str(user.id),
|
||
"email": user.email,
|
||
"billing_provider": user.billing_provider,
|
||
"old_billing_provider": old,
|
||
})
|
||
|
||
|
||
# ============================================================
|
||
# Heicode 企业成员邀请 §Q1-b — 内部端点:按邮箱预开通可登录账号
|
||
# HM 在管理员发邀请时调本端点为被邀请 email 预建账号;被邀请人随后走 magic-link
|
||
# 登录即可成功(user 已存在,落在 D-1「仅登录已存在 user」内,**不改动 magic-link/D-1**)。
|
||
# ============================================================
|
||
|
||
|
||
class _ProvisionRequest(BaseModel):
|
||
"""企业邀请预开通请求体。"""
|
||
email: str
|
||
display_name: Optional[str] = None
|
||
invite_source: Optional[str] = None # 仅透传/记录,不建 org 模型
|
||
|
||
|
||
@router.post("/internal/provision", response_model=SuccessResponse)
|
||
async def provision_user(
|
||
payload: _ProvisionRequest,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""按邮箱预开通可登录账号(企业邀请 §Q1-b)。
|
||
|
||
- 鉴权:`HEICODE_INTERNAL_SERVICE_TOKEN`(同 /internal/billing-provider,非 user JWT)。
|
||
- email 归一化:`strip().lower()`(Q3 双方统一小写)。
|
||
- 幂等:已存在(大小写不敏感)→ 返回 `exists=true`,不重复建、不报错。
|
||
- 零默认资源:**不**分配 taiji 的余额/配额/LiteLLM key(企业成员算力来自 HM 组织团队池)。
|
||
- 无密码:`password_hash` 置随机不可用值 → 密码登录进不来,仅 magic-link 可登录。
|
||
"""
|
||
_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 无效"},
|
||
)
|
||
|
||
# 幂等:大小写不敏感查已存在用户
|
||
result = await db.execute(select(User).where(func.lower(User.email) == email))
|
||
existing = result.scalar_one_or_none()
|
||
if existing is not None:
|
||
logger.info("provision_user_exists", email=email, actor="heicode_backend_internal")
|
||
return SuccessResponse(data={"email": existing.email, "exists": True})
|
||
|
||
TAIJI_CHANNEL_ID = uuid.UUID("b415e70b-8d37-481c-b229-bc3b7871607b")
|
||
# 无密码:随机不可用 hash(无人知晓 → 密码登录不可能;magic-link 登录不校验密码)
|
||
unusable_hash = get_password_hash(secrets.token_urlsafe(32))
|
||
display_name = (payload.display_name or "").strip() or email
|
||
|
||
new_user = User(
|
||
name=display_name,
|
||
email=email,
|
||
password_hash=unusable_hash,
|
||
hashed_password=unusable_hash, # 兼容字段
|
||
full_name=display_name,
|
||
role="user",
|
||
channel_id=TAIJI_CHANNEL_ID, # 归属 taiji 渠道(仅登录产物一致性,不含任何资源)
|
||
status="active",
|
||
is_active=True,
|
||
is_admin=False,
|
||
credit_limit=0,
|
||
total_eu_consumed=0,
|
||
)
|
||
try:
|
||
db.add(new_user)
|
||
await db.commit()
|
||
await db.refresh(new_user)
|
||
except IntegrityError:
|
||
# 并发:另一路已建(email 唯一约束)→ 幂等返回
|
||
await db.rollback()
|
||
result = await db.execute(select(User).where(func.lower(User.email) == email))
|
||
existing = result.scalar_one_or_none()
|
||
if existing is not None:
|
||
return SuccessResponse(data={"email": existing.email, "exists": True})
|
||
raise
|
||
|
||
logger.info(
|
||
"provision_user_created",
|
||
user_id=str(new_user.id),
|
||
email=email,
|
||
invite_source=payload.invite_source,
|
||
actor="heicode_backend_internal",
|
||
)
|
||
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 headless 设备登录(device-code / RFC 8628)
|
||
# authorize/token 公开(同 /login);approve 走 require_auth(批准人 = 当前登录用户)。
|
||
# token 产物复用 create_access_token/refresh,与 /api/auth/login 逐字段一致。
|
||
# ============================================================
|
||
|
||
|
||
class _DeviceAuthorizeRequest(BaseModel):
|
||
client: Optional[str] = None # 仅审计/展示
|
||
|
||
|
||
@router.post("/device/authorize", response_model=SuccessResponse)
|
||
async def device_authorize(payload: _DeviceAuthorizeRequest):
|
||
"""RFC 8628:headless 设备发起设备授权,拿 user_code + 验证页地址。无需身份。"""
|
||
device_code, user_code = await create_device_authorization(payload.client)
|
||
if not device_code:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail={"code": "DEVICE_AUTH_STORE_UNAVAILABLE", "message": "设备授权存储不可用"},
|
||
)
|
||
return SuccessResponse(data={
|
||
"device_code": device_code,
|
||
"user_code": user_code,
|
||
"verification_uri": DEVICE_VERIFICATION_URI,
|
||
"verification_uri_complete": f"{DEVICE_VERIFICATION_URI}?code={user_code}",
|
||
"expires_in": DEVICE_CODE_TTL_SECONDS,
|
||
"interval": DEVICE_POLL_INTERVAL_SECONDS,
|
||
})
|
||
|
||
|
||
class _DeviceApproveRequest(BaseModel):
|
||
user_code: str
|
||
approve: bool = True
|
||
|
||
|
||
@router.post("/device/approve", response_model=SuccessResponse)
|
||
async def device_approve(
|
||
payload: _DeviceApproveRequest,
|
||
principal: dict = Depends(require_auth),
|
||
):
|
||
"""验证页(HM)在用户登录后调用:把 user_code 绑定到**当前登录用户**(批准人)。
|
||
approve=false 为拒绝。批准人身份**仅取自 token**,绝不取设备侧输入(契约 §4)。"""
|
||
user_code = (payload.user_code or "").strip().upper()
|
||
device_code, state = await lookup_by_user_code(user_code)
|
||
if not device_code or not state:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "INVALID_USER_CODE", "message": "user_code 无效或已过期"})
|
||
if state.get("status") != "pending":
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
|
||
detail={"code": "ALREADY_HANDLED", "message": f"该 user_code 已处理({state.get('status')})"})
|
||
user_id = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
|
||
ok = await set_device_decision(device_code, state, bool(payload.approve),
|
||
user_id=str(user_id) if user_id else None)
|
||
if not ok:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail={"code": "DEVICE_STATE_WRITE_FAILED", "message": "设备状态写入失败"})
|
||
logger.info("device_approve", user_code=user_code, approved=bool(payload.approve),
|
||
approver=str(user_id), client=state.get("client"))
|
||
return SuccessResponse(data={"client": state.get("client"), "approved": bool(payload.approve)})
|
||
|
||
|
||
class _DeviceTokenRequest(BaseModel):
|
||
device_code: str
|
||
|
||
|
||
@router.post("/device/token")
|
||
async def device_token(payload: _DeviceTokenRequest, db: AsyncSession = Depends(get_db)):
|
||
"""RFC 8628 轮询换 token。未批准前 400 + error(authorization_pending/slow_down/
|
||
access_denied/expired_token);批准后 200 + 与 /login 逐字段一致的 token 产物。"""
|
||
def err(code: str):
|
||
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST,
|
||
content={"success": False, "error": code})
|
||
|
||
dc = payload.device_code
|
||
state = await get_device_state(dc)
|
||
if not state:
|
||
return err("expired_token") # 不存在/过期
|
||
st = state.get("status")
|
||
if st == "denied":
|
||
return err("access_denied")
|
||
if st == "consumed":
|
||
return err("expired_token") # 已换发(一次性)
|
||
# slow_down:距上次轮询 < interval(首轮 last_poll=0 放行)
|
||
last = float(state.get("last_poll") or 0)
|
||
if last and (time.time() - last) < DEVICE_POLL_INTERVAL_SECONDS:
|
||
return err("slow_down")
|
||
await mark_poll(dc, state)
|
||
if st == "pending":
|
||
return err("authorization_pending")
|
||
if st == "approved":
|
||
user_id = state.get("user_id")
|
||
user = None
|
||
if user_id:
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if user is None:
|
||
return err("access_denied") # 批准人不存在(异常兜底)
|
||
token_data = {
|
||
"sub": str(user.id),
|
||
"email": user.email,
|
||
"role": user.role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
}
|
||
access_token = create_access_token(data=token_data)
|
||
refresh_token = create_refresh_token(data=token_data)
|
||
await consume_device_code(dc, state) # 一次性作废(置 consumed + 删 key)
|
||
logger.info("device_token_issued", user_id=str(user.id), client=state.get("client"))
|
||
return SuccessResponse(data={
|
||
"token": access_token,
|
||
"refreshToken": refresh_token,
|
||
"user": {
|
||
"id": str(user.id),
|
||
"name": user.name or user.full_name,
|
||
"email": user.email,
|
||
"role": user.role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
},
|
||
})
|
||
return err("authorization_pending")
|
||
|
||
|
||
# ============================================================
|
||
# Heicode magic-link 邮箱登录(§2 契约 / §8 D-1·D-2·D-3 / §11 D-4=APIM / §12)
|
||
# —— 纯新增的并行登录方式:不输密码,邮箱收链接,点链接回跳客户端完成登录。
|
||
# 绝不改动 /login、/me、/refresh、/logout、/register 任何现有行为(满足 §1.1)。
|
||
# · D-1 仅登录:verify 只对**已存在 user** 签发登录产物,绝不触发 register provisioning;
|
||
# 未注册邮箱在 request 阶段静默不发信。
|
||
# · D-2 防枚举:request 无论邮箱是否注册一律返回成功。
|
||
# · D-3 仅 role=user:channel/admin 继续走密码登录。
|
||
# · §1.3 登录产物等价:verify 复用 create_access_token/create_refresh_token
|
||
# + 与 /login 逐字段相同的 token_data → EU/计费零改动。
|
||
# 三端点均**不声明 Depends(require_auth)** 即公开(§12.2 已证实,无需改 allow_paths)。
|
||
# ============================================================
|
||
|
||
# 与密码登录独立的限流计数器(IP 维度 5 次/60s,对齐 §2.1)
|
||
_magic_link_rate_limit = _LoginRateLimit()
|
||
|
||
|
||
class _MagicLinkRequest(BaseModel):
|
||
"""magic-link/request 请求体。"""
|
||
email: EmailStr
|
||
|
||
|
||
class _MagicLinkVerify(BaseModel):
|
||
"""magic-link/verify 请求体。device_pubkey 可选,我方忽略(设备配对在 HM 侧,
|
||
见契约 §3 / §7.1 D-4),且**不改变** token 结构。"""
|
||
code: str
|
||
state: str
|
||
device_pubkey: Optional[str] = None
|
||
|
||
|
||
_LANDING_INVALID_HTML = """<!DOCTYPE html>
|
||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>链接无效</title></head>
|
||
<body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#222;text-align:center">
|
||
<h2>登录链接无效或已过期</h2>
|
||
<p>请回到 HeiCode 客户端重新获取登录链接。</p>
|
||
<p style="color:#888;font-size:14px">提示:请在已安装 HeiCode 的同一台设备上打开邮件链接。</p>
|
||
</body></html>"""
|
||
|
||
|
||
@router.post("/magic-link/request", response_model=SuccessResponse)
|
||
async def magic_link_request(
|
||
req: _MagicLinkRequest,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
_: None = Depends(_magic_link_rate_limit),
|
||
):
|
||
"""申请 magic-link 登录链接(契约 §2.1)。
|
||
|
||
防枚举(D-2):无论邮箱是否注册一律返回成功;仅当邮箱对应**已存在的
|
||
role=user 用户**(D-1 仅登录 + D-3)时才真正生成 token 并发信,其余情况静默。
|
||
"""
|
||
email = (req.email or "").strip().lower() # Q3 双方统一小写:归一化输入
|
||
# state 始终下发(客户端存下,回跳时严格比对);request_id 供追踪。
|
||
state = secrets.token_urlsafe(16)
|
||
request_id = str(uuid.uuid4())
|
||
|
||
# 邮箱维度限流(§13.2):在查用户之前判定,且对存在/不存在邮箱一视同仁,
|
||
# 既防止对真实用户的邮件轰炸,又不泄漏邮箱存在性(D-2 防枚举)。
|
||
can_send, remaining = await check_email_rate_limit(email)
|
||
if not can_send:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail=f"请等待{remaining}秒后再重新申请登录链接",
|
||
headers={"Retry-After": str(remaining)},
|
||
)
|
||
# 无论后续是否真正发信,都先打上冷却(防枚举)
|
||
await set_email_rate_limit(email)
|
||
|
||
# 大小写不敏感查询,兼容历史混合大小写行 + 命中预开通的小写账号(Q3)
|
||
result = await db.execute(select(User).where(func.lower(User.email) == email))
|
||
user = result.scalar_one_or_none()
|
||
|
||
if user is not None and user.role == "user":
|
||
token = generate_magic_link_token()
|
||
# token 绑定用户**实际存储**的 email,landing/verify 据此精确查得到该用户
|
||
bind_email = user.email
|
||
stored = await store_magic_link_token(token, bind_email, state)
|
||
if stored:
|
||
base = settings.magic_link_public_base_url.rstrip("/")
|
||
link = f"{base}/api/auth/magic-link/landing?token={token}&state={state}"
|
||
# 发信失败不影响响应(防枚举 + 邮件基建未就位时静默,见 §12.4)
|
||
await send_magic_link_email(bind_email, link)
|
||
else:
|
||
logger.error("magic_link_token_store_failed", email=bind_email)
|
||
else:
|
||
# 未注册 / 非 user 角色:静默不发信(与防枚举一致)
|
||
logger.info("magic_link_request_silent_skip", email=email)
|
||
|
||
return SuccessResponse(data={
|
||
"request_id": request_id,
|
||
"state": state,
|
||
"expires_in_sec": MAGIC_LINK_TOKEN_TTL_SECONDS,
|
||
})
|
||
|
||
|
||
@router.get("/magic-link/landing")
|
||
async def magic_link_landing(
|
||
token: str = Query(..., description="magic-link token"),
|
||
state: str = Query(..., description="客户端 state"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""邮件链接指向的落地页(契约 §2.2)。浏览器直接打开,经 APIM 反代透传(§11)。
|
||
|
||
校验 token(存在/未过期/未用,一次性消费)+ state 一致 + user 仍存在且
|
||
role=user → 生成一次性 code(≤2min)并 **302 跳转** 到桌面深链
|
||
`heicode://auth/callback?code=...&state=...`;任一校验失败返回人类可读 HTML。
|
||
"""
|
||
payload = await consume_magic_link_token(token)
|
||
if not payload or payload.get("state") != state:
|
||
return HTMLResponse(content=_LANDING_INVALID_HTML, status_code=status.HTTP_400_BAD_REQUEST)
|
||
|
||
email = payload.get("email")
|
||
result = await db.execute(select(User).where(User.email == email))
|
||
user = result.scalar_one_or_none()
|
||
# D-3:仅 role=user;用户被删/改角色则视为无效
|
||
if user is None or user.role != "user":
|
||
return HTMLResponse(content=_LANDING_INVALID_HTML, status_code=status.HTTP_400_BAD_REQUEST)
|
||
|
||
code = generate_one_time_code()
|
||
stored = await store_one_time_code(code, str(user.id), email, state)
|
||
if not stored:
|
||
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)
|
||
|
||
|
||
@router.post("/magic-link/verify", response_model=SuccessResponse)
|
||
async def magic_link_verify(
|
||
req: _MagicLinkVerify,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
_: None = Depends(_magic_link_rate_limit),
|
||
):
|
||
"""用一次性 code 换登录态(契约 §2.3)。
|
||
|
||
成功响应与 `POST /api/auth/login`(user 分支)**逐字段一致**:同 token_data、
|
||
同 create_access_token/create_refresh_token、同 24h/7d TTL、同 {token,
|
||
refreshToken, user{id,name,email,role,channelId}}。→ EU/计费零改动(§1.3)。
|
||
"""
|
||
success = False
|
||
result_user_id: Optional[str] = None
|
||
error_msg: Optional[str] = None
|
||
try:
|
||
payload = await consume_one_time_code(req.code)
|
||
if not payload or payload.get("state") != req.state:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="登录凭证无效或已过期",
|
||
)
|
||
|
||
# 按 code 绑定的 user_id 解析(D-3:仅 role=user)
|
||
uid_raw = payload.get("user_id")
|
||
try:
|
||
user = await db.get(User, uuid.UUID(str(uid_raw)))
|
||
except (ValueError, TypeError):
|
||
user = None
|
||
if user is None or user.role != "user":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="登录凭证无效或已过期",
|
||
)
|
||
|
||
# 与 /login 一致:更新最后登录时间
|
||
user.last_login_at = datetime.utcnow()
|
||
await db.commit()
|
||
|
||
# 与 /login user 分支逐字段相同的 token_data
|
||
token_data = {
|
||
"sub": str(user.id),
|
||
"email": user.email,
|
||
"role": user.role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
}
|
||
access_token = create_access_token(data=token_data)
|
||
refresh_token = create_refresh_token(data=token_data)
|
||
|
||
success = True
|
||
result_user_id = str(user.id)
|
||
return SuccessResponse(
|
||
data={
|
||
"token": access_token,
|
||
"refreshToken": refresh_token,
|
||
"user": {
|
||
"id": str(user.id),
|
||
"name": user.name or user.full_name,
|
||
"email": user.email,
|
||
"role": user.role,
|
||
"channelId": str(user.channel_id) if user.channel_id else None,
|
||
},
|
||
}
|
||
)
|
||
except HTTPException as e:
|
||
error_msg = e.detail if isinstance(e.detail, str) else str(e.detail)
|
||
raise
|
||
finally:
|
||
try:
|
||
await log_audit_event(
|
||
action="auth.login",
|
||
resource_type="user",
|
||
resource_id=result_user_id,
|
||
user_id=result_user_id,
|
||
success=success,
|
||
details={"role": "user", "method": "magic_link"},
|
||
error_message=error_msg,
|
||
request=request,
|
||
db=db,
|
||
)
|
||
except Exception as audit_exc:
|
||
logger.warning("magic_link_verify_audit_failed", error=str(audit_exc))
|
||
|