Files
aiagent/backend/app/api/auth.py
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments)
- Add company orchestrator, knowledge extractor, presets, scheduler
- Add company API endpoints, models, and frontend views
- Add 今天吃啥 PWA app (69 dishes, real images, offline support)
- Add team_projects output directory structure
- Add unified manage.ps1 for service lifecycle
- Add Windows startup guide v1.0
- Add TTS troubleshooting doc
- Update frontend (AgentChat UX overhaul, new views)
- Update backend (voice engine fix, multi-tenant, RBAC)
- Remove deprecated startup scripts and old docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-10 22:37:56 +08:00

1008 lines
32 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.
"""
认证相关API
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status, Form
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from pydantic import BaseModel, field_validator
import re
import secrets
import uuid
import io
import base64
import random
import string
import logging
from app.core.database import get_db
from app.core.security import (
verify_password, get_password_hash, create_access_token,
create_refresh_token, verify_refresh_token, revoke_refresh_token,
)
from app.models.user import User
from datetime import datetime, timedelta
from app.core.config import settings
from app.core.exceptions import ConflictError, UnauthorizedError, NotFoundError
from app.core.redis_client import get_redis_client
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1/auth",
tags=["auth"],
responses={
401: {"description": "未授权"},
400: {"description": "请求参数错误"},
500: {"description": "服务器内部错误"}
}
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl="/api/v1/auth/login", auto_error=False
)
class UserCreate(BaseModel):
"""用户创建模型"""
username: str
email: str
password: str
agreed_terms: bool = False
agreed_terms_version: str | None = None
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
if not v or not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
raise ValueError("邮箱格式无效")
return v.lower()
@field_validator("agreed_terms")
@classmethod
def must_agree_terms(cls, v: bool) -> bool:
if not v:
raise ValueError("必须同意用户协议和隐私政策")
return v
class UserResponse(BaseModel):
"""用户响应模型"""
id: str
username: str
email: str
role: str
phone: str | None = None
class Config:
from_attributes = True
class MeResponse(BaseModel):
"""当前用户完整信息(含工作区列表)"""
id: str
username: str
email: str
role: str
phone: str | None = None
status: str = "active"
is_email_verified: bool = False
workspaces: list = []
current_workspace_id: str | None = None
class Token(BaseModel):
"""令牌响应模型"""
access_token: str
token_type: str = "bearer"
refresh_token: str | None = None
class RefreshRequest(BaseModel):
"""刷新令牌请求"""
refresh_token: str
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""用户注册"""
# 检查用户名是否已存在
if db.query(User).filter(User.username == user_data.username).first():
raise ConflictError("用户名已存在")
# 检查邮箱是否已存在
if db.query(User).filter(User.email == user_data.email).first():
raise ConflictError("邮箱已存在")
# 创建新用户
hashed_password = get_password_hash(user_data.password)
user = User(
username=user_data.username,
email=user_data.email,
password_hash=hashed_password,
agreed_terms=user_data.agreed_terms,
agreed_terms_version=user_data.agreed_terms_version,
agreed_terms_at=datetime.utcnow() if user_data.agreed_terms else None,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def _get_user_default_workspace_id(db: Session, user: User) -> str | None:
"""获取用户的默认工作区 ID。优先使用默认工作区其次第一个 membership。"""
from app.models.workspace import Workspace, WorkspaceMembership
# 优先使用系统默认工作区
default_ws = db.query(Workspace).filter(Workspace.is_default == 1, Workspace.status == "active").first()
if default_ws:
membership = (
db.query(WorkspaceMembership)
.filter(
WorkspaceMembership.workspace_id == default_ws.id,
WorkspaceMembership.user_id == user.id,
)
.first()
)
if membership:
return default_ws.id
# 没有默认工作区,使用第一个 membership
first_membership = (
db.query(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
.first()
)
if first_membership:
return first_membership.workspace_id
return None
# ─── 图形验证码 & 登录失败计数 ───────────────────────────────
CAPTCHA_TTL_SEC = 120 # 验证码 2 分钟有效
LOGIN_FAIL_TTL_SEC = 15 * 60 # 失败计数窗口 15 分钟
LOGIN_CAPTCHA_THRESHOLD = 3 # 失败达到此次数后要求验证码
REMEMBER_ME_EXPIRE_MINUTES = 30 * 24 * 60 # 记住我30 天
def _gen_captcha_text(length: int = 4) -> str:
# 去除易混淆字符 0/O/1/I/L
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
return "".join(random.choice(alphabet) for _ in range(length))
def _render_captcha_image(text: str) -> str:
"""用 Pillow 生成验证码 PNG返回 data:image/png;base64,... 字符串。"""
from PIL import Image, ImageDraw, ImageFont
width, height = 120, 40
img = Image.new("RGB", (width, height), (245, 247, 250))
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("arial.ttf", 28)
except Exception:
font = ImageFont.load_default()
# 干扰线
for _ in range(5):
draw.line(
[(random.randint(0, width), random.randint(0, height)),
(random.randint(0, width), random.randint(0, height))],
fill=tuple(random.randint(150, 200) for _ in range(3)),
width=1,
)
# 逐字绘制,带随机颜色与位置抖动
for i, ch in enumerate(text):
draw.text(
(10 + i * 26 + random.randint(-2, 2), random.randint(2, 8)),
ch,
font=font,
fill=(random.randint(20, 90), random.randint(20, 90), random.randint(90, 160)),
)
# 干扰点
for _ in range(60):
draw.point((random.randint(0, width), random.randint(0, height)),
fill=tuple(random.randint(120, 200) for _ in range(3)))
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def _verify_captcha(captcha_id: str, code: str) -> bool:
"""校验图形验证码(一次性,校验后立即删除)。"""
if not captcha_id or not code:
return False
r = get_redis_client()
if not r:
return False
key = f"captcha:{captcha_id}"
stored = r.get(key)
if stored is None:
return False
r.delete(key) # 一次性,无论对错都作废
if isinstance(stored, bytes):
stored = stored.decode()
return str(stored).upper() == code.strip().upper()
def _fail_key(username: str) -> str:
return f"login_fail:{(username or '').strip().lower()}"
def _get_login_fail_count(username: str) -> int:
r = get_redis_client()
if not r:
return 0
v = r.get(_fail_key(username))
try:
return int(v) if v is not None else 0
except (TypeError, ValueError):
return 0
def _incr_login_fail(username: str) -> int:
r = get_redis_client()
if not r:
return 0
key = _fail_key(username)
count = r.incr(key)
if count == 1:
r.expire(key, LOGIN_FAIL_TTL_SEC)
return int(count)
def _reset_login_fail(username: str):
r = get_redis_client()
if r:
r.delete(_fail_key(username))
@router.get("/captcha")
async def get_captcha():
"""生成图形验证码,返回 {captcha_id, image(data URL)}。"""
text = _gen_captcha_text()
captcha_id = str(uuid.uuid4())
r = get_redis_client()
if r:
r.setex(f"captcha:{captcha_id}", CAPTCHA_TTL_SEC, text)
return {"captcha_id": captcha_id, "image": _render_captcha_image(text)}
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
remember_me: bool = Form(False),
captcha_id: str = Form(""),
captcha: str = Form(""),
db: Session = Depends(get_db),
client_type: str = "web"
):
"""用户登录。
- client_type=android/ios 时签发 7 天 token
- web 端默认 30 分钟,勾选「记住我」(remember_me) 则签发 30 天;
- 同一用户名连续失败 >= 3 次后,必须携带图形验证码 (captcha_id + captcha)。
"""
username = form_data.username
fail_count = _get_login_fail_count(username)
# 失败次数达到阈值:强制校验图形验证码
if fail_count >= LOGIN_CAPTCHA_THRESHOLD:
if not _verify_captcha(captcha_id, captcha):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "验证码错误或已过期", "captcha_required": True},
)
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(form_data.password, user.password_hash):
new_count = _incr_login_fail(username)
logger.warning(f"登录失败: 用户名 {username}, user_found={user is not None}, 失败次数={new_count}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"message": "用户名或密码错误",
"captcha_required": new_count >= LOGIN_CAPTCHA_THRESHOLD,
},
)
# 登录成功:清零失败计数
_reset_login_fail(username)
from datetime import timedelta
if client_type in ("android", "ios"):
expires = timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES)
elif remember_me:
expires = timedelta(minutes=REMEMBER_ME_EXPIRE_MINUTES)
else:
expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
ws_id = _get_user_default_workspace_id(db, user)
access_token = create_access_token(
data={"sub": user.id, "username": user.username, "ws": ws_id or ""},
expires_delta=expires,
)
refresh_token = create_refresh_token(
user_id=user.id,
username=user.username,
workspace_id=ws_id or "",
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token,
}
async def get_current_user(
token: str = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db),
request: Request = None,
) -> User:
"""FastAPI 依赖 — 从 JWT 或 X-API-Key 提取当前用户,返回 User 模型。
优先级JWT Bearer Token > X-API-Key Header
"""
from app.core.security import decode_access_token
from app.models.api_key import verify_api_key as _verify_api_key
# 尝试 JWT 认证
if token and token not in ("undefined", "null"):
payload = decode_access_token(token)
if payload:
user_id = payload.get("sub")
if user_id:
user = db.query(User).filter(User.id == user_id).first()
if user and user.status != "deleted":
return user
# 尝试 API Key 认证
if request:
api_key_str = request.headers.get("X-API-Key", "").strip()
if api_key_str:
api_key = _verify_api_key(api_key_str, db)
if api_key:
return api_key.user
raise UnauthorizedError("未提供有效的认证令牌")
@router.get("/me", response_model=MeResponse)
async def get_me(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
):
"""获取当前用户信息(含工作区列表)。"""
from app.core.security import decode_access_token
from app.services.workspace_service import get_user_workspaces
payload = decode_access_token(token)
if payload is None:
raise UnauthorizedError("无效的访问令牌")
user_id = payload.get("sub")
if user_id is None:
raise UnauthorizedError("无效的访问令牌")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise NotFoundError("用户", user_id)
workspaces = get_user_workspaces(db, user)
current_ws_id = payload.get("ws", "")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"phone": user.phone,
"status": user.status or "active",
"is_email_verified": user.is_email_verified or False,
"workspaces": workspaces,
"current_workspace_id": current_ws_id if current_ws_id else None,
}
@router.post("/switch-workspace/{workspace_id}")
async def switch_workspace(
workspace_id: str,
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
):
"""切换当前工作区,重新签发 JWT包含新的 ws 字段)。"""
from app.core.security import decode_access_token
from app.services.workspace_service import check_workspace_access
payload = decode_access_token(token)
if payload is None:
raise UnauthorizedError("无效的访问令牌")
user_id = payload.get("sub")
if user_id is None:
raise UnauthorizedError("无效的访问令牌")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise NotFoundError("用户", user_id)
if not check_workspace_access(db, user, workspace_id):
raise HTTPException(status_code=403, detail="无权访问此工作区")
from datetime import timedelta
expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
new_token = create_access_token(
data={"sub": user.id, "username": user.username, "ws": workspace_id},
expires_delta=expires,
)
return {"access_token": new_token, "token_type": "bearer", "workspace_id": workspace_id}
# ─── Token 刷新 & 撤销 ────────────────────────────────────────
@router.post("/refresh", response_model=Token)
async def refresh_access_token(body: RefreshRequest):
"""用 refresh_token 换取新的 access_token同时轮换 refresh_token"""
data = verify_refresh_token(body.refresh_token)
if not data:
raise UnauthorizedError("无效或已过期的刷新令牌")
user_id = data.get("user_id", "")
username = data.get("username", "")
ws_id = data.get("ws", "")
from datetime import timedelta
access_token = create_access_token(
data={"sub": user_id, "username": username, "ws": ws_id},
expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES),
)
# 撤销旧 refresh token签发新的轮换
revoke_refresh_token(body.refresh_token)
new_refresh_token = create_refresh_token(
user_id=user_id,
username=username,
workspace_id=ws_id,
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": new_refresh_token,
}
@router.post("/revoke")
async def revoke_token(body: RefreshRequest):
"""撤销 refresh token退出登录时调用"""
revoke_refresh_token(body.refresh_token)
return {"message": "令牌已撤销"}
# ─── 密码重置 ───────────────────────────────────────────────
RESET_CODE_TTL_SEC = 600 # 验证码 10 分钟有效
RESET_RATE_LIMIT_SEC = 60 # 同一邮箱 60 秒内只能发一次
class ForgotPasswordRequest(BaseModel):
email: str
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
if not v or not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
raise ValueError("邮箱格式无效")
return v.lower()
class ResetPasswordRequest(BaseModel):
email: str
code: str
new_password: str
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
if not v or not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
raise ValueError("邮箱格式无效")
return v.lower()
@field_validator("new_password")
@classmethod
def password_length(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("密码不少于 6 个字符")
if len(v) > 32:
raise ValueError("密码不超过 32 个字符")
return v
async def _send_reset_email(email: str, code: str) -> bool:
"""发送密码重置邮件。SMTP 不可用时记日志。"""
try:
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
smtp_host = getattr(settings, 'SMTP_HOST', '') or 'smtp.qq.com'
smtp_port = int(getattr(settings, 'SMTP_PORT', 0) or 587)
smtp_user = getattr(settings, 'SMTP_USER', '') or ''
smtp_password = getattr(settings, 'SMTP_PASSWORD', '') or ''
if not smtp_user or not smtp_password:
logger.warning("SMTP 未配置,无法发送邮件。重置码: %s", code)
return False
msg = MIMEMultipart()
msg['From'] = smtp_user
msg['To'] = email
msg['Subject'] = '天工智能体 - 密码重置验证码'
msg.attach(MIMEText(
f'您的密码重置验证码是:<b>{code}</b><br><br>'
f'验证码 10 分钟内有效。如非本人操作请忽略此邮件。',
'html', 'utf-8'
))
await aiosmtplib.send(
msg, hostname=smtp_host, port=smtp_port,
username=smtp_user, password=smtp_password,
use_tls=smtp_port == 587,
)
logger.info("密码重置邮件已发送至 %s", email)
return True
except Exception as e:
logger.warning("邮件发送失败: %s,重置码: %s", e, code)
return False
@router.post("/forgot-password")
async def forgot_password(body: ForgotPasswordRequest, db: Session = Depends(get_db)):
"""发送密码重置验证码。"""
user = db.query(User).filter(User.email == body.email).first()
if not user:
# 不泄露邮箱是否注册,统一返回成功
return {"message": "如果邮箱已注册,验证码已发送"}
redis = get_redis_client()
# 频率限制
rate_key = f"pwd_reset_rate:{body.email}"
if redis:
if redis.exists(rate_key):
ttl = redis.ttl(rate_key)
raise HTTPException(
status_code=429,
detail=f"操作过于频繁,请 {ttl} 秒后重试"
)
code = secrets.randbelow(900000) + 100000 # 6 位数字
code_str = str(code)
# 存储到 Redis
code_key = f"pwd_reset_code:{body.email}"
if redis:
redis.setex(code_key, RESET_CODE_TTL_SEC, code_str)
redis.setex(rate_key, RESET_RATE_LIMIT_SEC, "1")
else:
# 无 Redis 时用内存存储(重启失效)
if not hasattr(forgot_password, '_memory_store'):
forgot_password._memory_store = {}
forgot_password._memory_rate = {}
forgot_password._memory_store[body.email] = {
"code": code_str,
"expires_at": datetime.utcnow() + timedelta(seconds=RESET_CODE_TTL_SEC),
}
forgot_password._memory_rate[body.email] = \
datetime.utcnow() + timedelta(seconds=RESET_RATE_LIMIT_SEC)
# 尝试发送邮件
sent = await _send_reset_email(body.email, code_str)
if not sent:
# SMTP 未配置时记录验证码并返回(开发/测试环境)
logger.info("开发模式:%s 的密码重置验证码为 %s", body.email, code_str)
return {
"message": "验证码已生成",
"dev_code": code_str,
}
return {"message": "验证码已发送至邮箱"}
@router.post("/reset-password")
async def reset_password(body: ResetPasswordRequest, db: Session = Depends(get_db)):
"""使用验证码重置密码。"""
user = db.query(User).filter(User.email == body.email).first()
if not user:
raise HTTPException(status_code=400, detail="邮箱未注册")
redis = get_redis_client()
code_key = f"pwd_reset_code:{body.email}"
stored_code = None
if redis:
stored_code = redis.get(code_key)
elif hasattr(forgot_password, '_memory_store'):
entry = forgot_password._memory_store.get(body.email, {})
if entry and entry.get("expires_at", datetime.min) > datetime.utcnow():
stored_code = entry.get("code")
if not stored_code:
raise HTTPException(status_code=400, detail="验证码已过期或未请求")
if stored_code != body.code.strip():
raise HTTPException(status_code=400, detail="验证码错误")
# 更新密码
user.password_hash = get_password_hash(body.new_password)
db.commit()
# 清除验证码
if redis:
redis.delete(code_key)
elif hasattr(forgot_password, '_memory_store'):
forgot_password._memory_store.pop(body.email, None)
logger.info("用户 %s 密码重置成功", user.username)
return {"message": "密码重置成功,请使用新密码登录"}
# ─── 修改密码 ───────────────────────────────────────────────
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@field_validator("new_password")
@classmethod
def password_length(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("密码不少于6个字符")
if len(v) > 32:
raise ValueError("密码不超过32个字符")
return v
@router.put("/change-password")
async def change_password(
body: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""修改密码(需提供旧密码验证)。"""
if not verify_password(body.old_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="旧密码错误")
current_user.password_hash = get_password_hash(body.new_password)
db.commit()
logger.info("用户 %s 修改密码成功", current_user.username)
return {"message": "密码修改成功"}
# ─── 账号注销 ───────────────────────────────────────────────
class DeleteAccountRequest(BaseModel):
password: str
@router.delete("/account")
async def delete_account(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""注销账号(需提供密码确认)。软删除:设置 status='deleted'"""
if not verify_password(body.password, current_user.password_hash):
raise HTTPException(status_code=400, detail="密码错误")
# 软删除用户
current_user.status = "deleted"
current_user.email = f"deleted_{current_user.id}@deleted.local"
current_user.phone = None
current_user.feishu_open_id = None
db.commit()
logger.info("用户 %s 账号已注销", current_user.username)
return {"message": "账号已注销"}
# ─── 绑定手机号 ──────────────────────────────────────────────
class BindPhoneRequest(BaseModel):
phone: str
code: str | None = None # 验证码(对接短信服务后可启用)
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
@router.put("/phone")
async def bind_phone(
body: BindPhoneRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""绑定或修改手机号(需验证码校验)。"""
redis = get_redis_client()
# 必须提供验证码
if not body.code:
raise HTTPException(status_code=400, detail="请提供短信验证码")
# 校验验证码
if not _verify_sms_code(redis, body.phone, body.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
# 检查手机号是否已被其他用户绑定
existing = db.query(User).filter(User.phone == body.phone, User.id != current_user.id).first()
if existing:
raise ConflictError("该手机号已被其他用户绑定")
current_user.phone = body.phone
current_user.phone_verified = True
current_user.phone_verified_at = datetime.utcnow()
db.commit()
logger.info("用户 %s 绑定手机号成功", current_user.username)
return {"message": "手机号绑定成功", "phone": body.phone}
# ─── 手机验证码v1.1.0)────────────────────────────────────
SMS_CODE_TTL_SEC = 600 # 验证码 10 分钟有效
SMS_RATE_LIMIT_SEC = 60 # 同一手机号 60 秒内只能发一次
class SendSmsCodeRequest(BaseModel):
phone: str
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
class VerifyPhoneRequest(BaseModel):
phone: str
code: str
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
class PhoneLoginRequest(BaseModel):
phone: str
code: str
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
def _gen_sms_code() -> str:
"""生成 6 位数字验证码"""
return str(secrets.randbelow(900000) + 100000)
async def _send_sms_code(phone: str, code: str) -> bool:
"""发送短信验证码。返回 True 表示发送成功。"""
from app.core.sms_service import get_sms_provider
provider = get_sms_provider()
try:
return await provider.send(phone, code)
except Exception as e:
logger.warning("SMS 发送异常: %s", e)
return False
@router.post("/phone/send-code")
async def send_phone_code(body: SendSmsCodeRequest):
"""发送手机验证码。"""
redis = get_redis_client()
# 频率限制
rate_key = f"sms_rate:{body.phone}"
if redis:
if redis.exists(rate_key):
ttl = redis.ttl(rate_key)
raise HTTPException(
status_code=429,
detail=f"操作过于频繁,请 {ttl} 秒后重试"
)
code = _gen_sms_code()
# 存储到 Redis
code_key = f"sms_code:{body.phone}"
if redis:
redis.setex(code_key, SMS_CODE_TTL_SEC, code)
redis.setex(rate_key, SMS_RATE_LIMIT_SEC, "1")
else:
# 无 Redis 时回退内存存储
if not hasattr(send_phone_code, "_memory_store"):
send_phone_code._memory_store = {}
send_phone_code._memory_rate = {}
send_phone_code._memory_store[body.phone] = {
"code": code,
"expires_at": datetime.utcnow() + timedelta(seconds=SMS_CODE_TTL_SEC),
}
send_phone_code._memory_rate[body.phone] = \
datetime.utcnow() + timedelta(seconds=SMS_RATE_LIMIT_SEC)
# 发送验证码
sent = await _send_sms_code(body.phone, code)
if not sent:
# Mock 模式或发送失败时返回验证码(仅开发环境)
logger.info("开发模式:%s 的短信验证码为 %s", body.phone, code)
return {
"message": "验证码已生成Mock 模式)",
"dev_code": code,
}
return {"message": "验证码已发送"}
def _verify_sms_code(redis, phone: str, code: str) -> bool:
"""校验短信验证码(从 Redis 或内存存储中读取)。"""
code_key = f"sms_code:{phone}"
stored_code = None
if redis:
stored_code_raw = redis.get(code_key)
stored_code = stored_code_raw.decode() if isinstance(stored_code_raw, bytes) else stored_code_raw
elif hasattr(send_phone_code, "_memory_store"):
entry = send_phone_code._memory_store.get(phone, {})
if entry and entry.get("expires_at", datetime.min) > datetime.utcnow():
stored_code = entry.get("code")
if not stored_code:
return False
if stored_code != code.strip():
return False
# 验证通过后清除
if redis:
redis.delete(code_key)
elif hasattr(send_phone_code, "_memory_store"):
send_phone_code._memory_store.pop(phone, None)
return True
@router.post("/phone/verify")
async def verify_phone(
body: VerifyPhoneRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""验证手机号(绑定后验证)。"""
redis = get_redis_client()
if not _verify_sms_code(redis, body.phone, body.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
# 检查手机号是否已被其他用户绑定
existing = db.query(User).filter(
User.phone == body.phone, User.id != current_user.id
).first()
if existing:
raise HTTPException(status_code=400, detail="该手机号已被其他用户绑定")
current_user.phone = body.phone
current_user.phone_verified = True
current_user.phone_verified_at = datetime.utcnow()
db.commit()
logger.info("用户 %s 手机号 %s 验证成功", current_user.username, body.phone)
return {"message": "手机号验证成功", "phone": body.phone}
@router.post("/login/phone", response_model=Token)
async def phone_login(
body: PhoneLoginRequest,
db: Session = Depends(get_db),
):
"""手机号+验证码登录。用户不存在则自动注册。"""
redis = get_redis_client()
if not _verify_sms_code(redis, body.phone, body.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
user = db.query(User).filter(User.phone == body.phone).first()
if not user:
# 自动注册:用手机号生成用户名
username_base = f"user_{body.phone[-6:]}"
username = username_base
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{username_base}_{counter}"
counter += 1
user = User(
username=username,
email=f"{body.phone}@phone.local",
password_hash=get_password_hash(secrets.token_urlsafe(16)),
phone=body.phone,
phone_verified=True,
phone_verified_at=datetime.utcnow(),
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("手机号自动注册: %s%s", body.phone, username)
else:
# 如果手机号还没标记为已验证,更新
if not user.phone_verified:
user.phone_verified = True
user.phone_verified_at = datetime.utcnow()
db.commit()
if user.status == "deleted":
raise HTTPException(status_code=403, detail="该账号已注销")
ws_id = _get_user_default_workspace_id(db, user)
access_token = create_access_token(
data={"sub": user.id, "username": user.username, "ws": ws_id or ""},
expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES),
)
refresh_token = create_refresh_token(
user_id=user.id,
username=user.username,
workspace_id=ws_id or "",
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token,
}
async def get_optional_user(
token: str | None = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db)
) -> User | None:
"""获取当前用户(可选登录)。未提供 token 或 token 无效时返回 None。"""
if not token:
return None
from app.core.security import decode_access_token
payload = decode_access_token(token)
if payload is None:
return None
user_id = payload.get("sub")
if user_id is None:
return None
user = db.query(User).filter(User.id == user_id).first()
return user