2026-01-19 00:09:36 +08:00
|
|
|
|
"""
|
|
|
|
|
|
认证相关API
|
|
|
|
|
|
"""
|
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
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status, Form
|
2026-01-19 00:09:36 +08:00
|
|
|
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
2026-03-06 22:31:41 +08:00
|
|
|
|
from pydantic import BaseModel, field_validator
|
|
|
|
|
|
import re
|
2026-06-29 01:17:21 +08:00
|
|
|
|
import secrets
|
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
|
|
|
|
import uuid
|
|
|
|
|
|
import io
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import random
|
|
|
|
|
|
import string
|
2026-01-19 00:09:36 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
from app.core.database import get_db
|
2026-07-01 22:20:47 +08:00
|
|
|
|
from app.core.security import (
|
|
|
|
|
|
verify_password, get_password_hash, create_access_token,
|
|
|
|
|
|
create_refresh_token, verify_refresh_token, revoke_refresh_token,
|
|
|
|
|
|
)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
from app.models.user import User
|
2026-06-29 01:17:21 +08:00
|
|
|
|
from datetime import datetime, timedelta
|
2026-01-19 00:09:36 +08:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
from app.core.exceptions import ConflictError, UnauthorizedError, NotFoundError
|
2026-06-29 01:17:21 +08:00
|
|
|
|
from app.core.redis_client import get_redis_client
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-06-29 01:17:21 +08:00
|
|
|
|
oauth2_scheme_optional = OAuth2PasswordBearer(
|
|
|
|
|
|
tokenUrl="/api/v1/auth/login", auto_error=False
|
|
|
|
|
|
)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
|
|
|
|
"""用户创建模型"""
|
|
|
|
|
|
username: str
|
2026-03-06 22:31:41 +08:00
|
|
|
|
email: str
|
2026-01-19 00:09:36 +08:00
|
|
|
|
password: str
|
2026-07-01 22:01:54 +08:00
|
|
|
|
agreed_terms: bool = False
|
|
|
|
|
|
agreed_terms_version: str | None = None
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
2026-03-06 22:31:41 +08:00
|
|
|
|
@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()
|
|
|
|
|
|
|
2026-07-01 22:01:54 +08:00
|
|
|
|
@field_validator("agreed_terms")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def must_agree_terms(cls, v: bool) -> bool:
|
|
|
|
|
|
if not v:
|
|
|
|
|
|
raise ValueError("必须同意用户协议和隐私政策")
|
|
|
|
|
|
return v
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
|
|
|
|
"""用户响应模型"""
|
|
|
|
|
|
id: str
|
|
|
|
|
|
username: str
|
|
|
|
|
|
email: str
|
|
|
|
|
|
role: str
|
2026-07-01 22:01:54 +08:00
|
|
|
|
phone: str | None = None
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
|
from_attributes = True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:17:21 +08:00
|
|
|
|
class MeResponse(BaseModel):
|
|
|
|
|
|
"""当前用户完整信息(含工作区列表)"""
|
|
|
|
|
|
id: str
|
|
|
|
|
|
username: str
|
|
|
|
|
|
email: str
|
|
|
|
|
|
role: str
|
2026-07-01 22:01:54 +08:00
|
|
|
|
phone: str | None = None
|
|
|
|
|
|
status: str = "active"
|
|
|
|
|
|
is_email_verified: bool = False
|
2026-06-29 01:17:21 +08:00
|
|
|
|
workspaces: list = []
|
|
|
|
|
|
current_workspace_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
class Token(BaseModel):
|
|
|
|
|
|
"""令牌响应模型"""
|
|
|
|
|
|
access_token: str
|
|
|
|
|
|
token_type: str = "bearer"
|
2026-07-01 22:20:47 +08:00
|
|
|
|
refresh_token: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
|
|
|
|
"""刷新令牌请求"""
|
|
|
|
|
|
refresh_token: str
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-07-01 22:01:54 +08:00
|
|
|
|
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,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(user)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(user)
|
|
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:17:21 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
# ─── 图形验证码 & 登录失败计数 ───────────────────────────────
|
|
|
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
@router.post("/login", response_model=Token)
|
2026-06-29 01:17:21 +08:00
|
|
|
|
async def login(
|
|
|
|
|
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
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
|
|
|
|
remember_me: bool = Form(False),
|
|
|
|
|
|
captcha_id: str = Form(""),
|
|
|
|
|
|
captcha: str = Form(""),
|
2026-06-29 01:17:21 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
client_type: str = "web"
|
|
|
|
|
|
):
|
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
|
|
|
|
"""用户登录。
|
|
|
|
|
|
|
|
|
|
|
|
- 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()
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
if not user or not verify_password(form_data.password, user.password_hash):
|
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
|
|
|
|
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)
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
|
|
if client_type in ("android", "ios"):
|
|
|
|
|
|
expires = timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES)
|
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
|
|
|
|
elif remember_me:
|
|
|
|
|
|
expires = timedelta(minutes=REMEMBER_ME_EXPIRE_MINUTES)
|
2026-06-29 01:17:21 +08:00
|
|
|
|
else:
|
|
|
|
|
|
expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
|
|
|
|
|
|
|
|
|
|
ws_id = _get_user_default_workspace_id(db, user)
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
access_token = create_access_token(
|
2026-06-29 01:17:21 +08:00
|
|
|
|
data={"sub": user.id, "username": user.username, "ws": ws_id or ""},
|
|
|
|
|
|
expires_delta=expires,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
)
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
2026-07-01 22:20:47 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user(
|
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
|
|
|
|
token: str = Depends(oauth2_scheme_optional),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
request: Request = None,
|
2026-06-29 01:17:21 +08:00
|
|
|
|
) -> User:
|
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
|
|
|
|
"""FastAPI 依赖 — 从 JWT 或 X-API-Key 提取当前用户,返回 User 模型。
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
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
|
|
|
|
优先级: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("未提供有效的认证令牌")
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/me", response_model=MeResponse)
|
|
|
|
|
|
async def get_me(
|
|
|
|
|
|
token: str = Depends(oauth2_scheme),
|
|
|
|
|
|
db: Session = Depends(get_db)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
):
|
2026-06-29 01:17:21 +08:00
|
|
|
|
"""获取当前用户信息(含工作区列表)。"""
|
2026-01-19 00:09:36 +08:00
|
|
|
|
from app.core.security import decode_access_token
|
2026-06-29 01:17:21 +08:00
|
|
|
|
from app.services.workspace_service import get_user_workspaces
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
payload = decode_access_token(token)
|
|
|
|
|
|
if payload is None:
|
|
|
|
|
|
raise UnauthorizedError("无效的访问令牌")
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
user_id = payload.get("sub")
|
|
|
|
|
|
if user_id is None:
|
|
|
|
|
|
raise UnauthorizedError("无效的访问令牌")
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
|
|
if user is None:
|
|
|
|
|
|
raise NotFoundError("用户", user_id)
|
2026-06-29 01:17:21 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-01 22:01:54 +08:00
|
|
|
|
"phone": user.phone,
|
|
|
|
|
|
"status": user.status or "active",
|
|
|
|
|
|
"is_email_verified": user.is_email_verified or False,
|
2026-06-29 01:17:21 +08:00
|
|
|
|
"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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 22:20:47 +08:00
|
|
|
|
# ─── 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": "令牌已撤销"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:17:21 +08:00
|
|
|
|
# ─── 密码重置 ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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": "密码重置成功,请使用新密码登录"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 22:01:54 +08:00
|
|
|
|
# ─── 修改密码 ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
):
|
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
|
|
|
|
"""绑定或修改手机号(需验证码校验)。"""
|
|
|
|
|
|
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="验证码错误或已过期")
|
|
|
|
|
|
|
2026-07-01 22:01:54 +08:00
|
|
|
|
# 检查手机号是否已被其他用户绑定
|
|
|
|
|
|
existing = db.query(User).filter(User.phone == body.phone, User.id != current_user.id).first()
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
raise ConflictError("该手机号已被其他用户绑定")
|
|
|
|
|
|
|
|
|
|
|
|
current_user.phone = body.phone
|
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
|
|
|
|
current_user.phone_verified = True
|
|
|
|
|
|
current_user.phone_verified_at = datetime.utcnow()
|
2026-07-01 22:01:54 +08:00
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("用户 %s 绑定手机号成功", current_user.username)
|
|
|
|
|
|
return {"message": "手机号绑定成功", "phone": body.phone}
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
# ─── 手机验证码(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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:17:21 +08:00
|
|
|
|
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()
|
2026-01-19 00:09:36 +08:00
|
|
|
|
return user
|