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>
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
"""
|
||||
认证相关API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 (
|
||||
@@ -155,25 +160,162 @@ def _get_user_default_workspace_id(db: Session, user: User) -> str | None:
|
||||
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 分钟。"""
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
"""用户登录。
|
||||
|
||||
- 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):
|
||||
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("用户名或密码错误")
|
||||
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)
|
||||
|
||||
@@ -198,27 +340,36 @@ async def login(
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
token: str = Depends(oauth2_scheme_optional),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None,
|
||||
) -> User:
|
||||
"""FastAPI 依赖 — 从 JWT 提取当前用户,返回 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
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise UnauthorizedError("无效的访问令牌")
|
||||
# 尝试 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
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise UnauthorizedError("无效的访问令牌")
|
||||
# 尝试 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
|
||||
|
||||
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
|
||||
raise UnauthorizedError("未提供有效的认证令牌")
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
|
||||
Reference in New Issue
Block a user