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:
2026-07-10 22:37:56 +08:00
parent 3483c6b3be
commit f2e65a8fbb
259 changed files with 39239 additions and 3148 deletions

View File

@@ -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 天 tokenweb 默认 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)