Files
aiagent/backend/app/api/auth.py
renjianbo 876789fac1 feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements
- Backend: workspace_id isolation for 14 model tables + safe migration/backfill
- Backend: RBAC system with 4 roles and 23 permissions, seeded on startup
- Backend: workspace admin endpoints (list/manage all workspaces)
- Backend: admin user management API (CRUD, reset password)
- Backend: billing API with subscription plans, usage tracking, rate limiting
- Backend: fix system_logs.py UNION query and wrong column references
- Backend: WebSocket JWT auth and workspace enforcement
- Frontend: sidebar navigation replacing top dropdown menu
- Frontend: user management page (Users.vue) for admins
- Frontend: enhanced Workspaces.vue with admin table view
- Frontend: workspace RBAC computed properties in user store
- Android: agent marketplace, billing/subscription UI, onboarding wizard
- Android: phone login, analytics tracker, crash handler, network diagnostics
- Android: splash screen, encrypted token storage, app update enhancements
- Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix

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

857 lines
27 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, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from pydantic import BaseModel, field_validator
import re
import secrets
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
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
client_type: str = "web"
):
"""用户登录。client_type=android/ios 时签发 7 天 tokenweb 默认 30 分钟。"""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
logger.warning(f"登录失败: 用户名 {form_data.username}, user_found={user is not None}, pwd_len={len(form_data.password)}")
if user:
logger.warning(f" DB hash prefix: {user.password_hash[:30]}...")
raise UnauthorizedError("用户名或密码错误")
from datetime import timedelta
if client_type in ("android", "ios"):
expires = timedelta(minutes=settings.JWT_MOBILE_TOKEN_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),
db: Session = Depends(get_db)
) -> User:
"""FastAPI 依赖 — 从 JWT 提取当前用户,返回 User 模型。"""
from app.core.security import decode_access_token
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 user.status == "deleted":
raise UnauthorizedError("账号已注销")
return user
@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