0841b1a103
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
436 lines
19 KiB
Python
436 lines
19 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime, timedelta
|
|
import os
|
|
import secrets
|
|
from config import get_settings
|
|
from database import get_db
|
|
from services.upload_security import validate_upload
|
|
from services.password_policy import validate_password_strength
|
|
from models.animation import Animation
|
|
from models.classroom import ClassroomActivity
|
|
from models.community import Comment, Post, PostFavorite
|
|
from models.courseware import Courseware
|
|
from models.credit import CreditTransaction
|
|
from models.exam import Exam
|
|
from models.essay import EssayGrade
|
|
from models.exercise import Exercise
|
|
from models.lesson_plan import LessonPlan
|
|
from models.mindmap import MindMap
|
|
from models.reset_code import ResetCode
|
|
from models.resource import Resource, ResourceFavorite
|
|
from models.user import User
|
|
from schemas.user import CreditBenefitOut, UserLogin, UserRegister, UserUpdate, UserOut, Token, ProfileStatsOut, PasswordChange, SendCodeRequest, ResetPasswordRequest
|
|
from services.credits import DAILY_CHECKIN_CREDITS, grant_daily_checkin, has_credit_transaction_today, ensure_credit_account
|
|
from services.limiter import limiter
|
|
from services.auth import (
|
|
get_password_hash, verify_password,
|
|
create_access_token, create_refresh_token,
|
|
get_current_user,
|
|
)
|
|
from services.audit import log_action
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
|
|
|
|
|
def serialize_user(user: User, db: Session) -> dict:
|
|
account = ensure_credit_account(db, user)
|
|
data = UserOut.model_validate(user).model_dump()
|
|
data.update({
|
|
"credits": account.balance,
|
|
"total_credits_granted": account.total_granted,
|
|
"total_credits_used": account.total_used,
|
|
})
|
|
return data
|
|
|
|
|
|
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
|
|
@limiter.limit("5/minute")
|
|
def register(request: Request, data: UserRegister, db: Session = Depends(get_db)):
|
|
if db.query(User).filter(User.phone == data.phone).first():
|
|
raise HTTPException(status_code=400, detail="该手机号已注册")
|
|
pwd_err = validate_password_strength(data.password)
|
|
if pwd_err:
|
|
raise HTTPException(status_code=400, detail=pwd_err)
|
|
user = User(
|
|
phone=data.phone,
|
|
password_hash=get_password_hash(data.password),
|
|
name=data.name,
|
|
subject=data.subject,
|
|
school=data.school,
|
|
grade=data.grade,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
log_action(db, action="register", user=user, request=request, target_type="user", target_id=user.id, detail=f"注册手机号 {user.phone}")
|
|
return Token(
|
|
access_token=create_access_token(user.id),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=Token)
|
|
@limiter.limit("10/minute")
|
|
def login(request: Request, data: UserLogin, db: Session = Depends(get_db)):
|
|
user = db.query(User).filter(User.phone == data.phone).first()
|
|
if not user or not verify_password(data.password, user.password_hash):
|
|
log_action(db, action="login", request=request, target_type="user", detail=f"登录失败:{data.phone}", status="failed")
|
|
raise HTTPException(status_code=401, detail="手机号或密码错误")
|
|
if not user.is_active:
|
|
log_action(db, action="login", user=user, request=request, target_type="user", target_id=user.id, detail="账号已禁用", status="denied")
|
|
raise HTTPException(status_code=403, detail="账号已被禁用")
|
|
log_action(db, action="login", user=user, request=request, target_type="user", target_id=user.id, detail="登录成功")
|
|
return Token(
|
|
access_token=create_access_token(user.id),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=Token)
|
|
@limiter.limit("30/minute")
|
|
def refresh_token(request: Request, refresh_token: str, db: Session = Depends(get_db)):
|
|
"""用 refresh_token 换取新的 access_token(与新的 refresh_token)。"""
|
|
from jose import jwt, JWTError
|
|
from config import get_settings as _gs
|
|
_s = _gs()
|
|
try:
|
|
payload = jwt.decode(refresh_token, _s.SECRET_KEY, algorithms=[_s.ALGORITHM])
|
|
if payload.get("type") != "refresh":
|
|
raise HTTPException(status_code=401, detail="无效的刷新凭证")
|
|
user_id = int(payload.get("sub"))
|
|
except (JWTError, TypeError, ValueError):
|
|
log_action(db, action="refresh_token", request=request, detail="刷新令牌无效或过期", status="failed")
|
|
raise HTTPException(status_code=401, detail="刷新凭证无效或已过期,请重新登录")
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="用户不存在")
|
|
if not user.is_active:
|
|
raise HTTPException(status_code=403, detail="账号已被禁用")
|
|
|
|
log_action(db, action="refresh_token", user=user, request=request, target_type="user", target_id=user.id, detail="刷新访问令牌")
|
|
return Token(
|
|
access_token=create_access_token(user.id),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
def get_profile(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
db.commit()
|
|
return serialize_user(current_user, db)
|
|
|
|
|
|
@router.put("/me", response_model=UserOut)
|
|
def update_profile(
|
|
data: UserUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
for key, value in update_data.items():
|
|
setattr(current_user, key, value)
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
return serialize_user(current_user, db)
|
|
|
|
|
|
@router.post("/change-password", response_model=dict)
|
|
def change_password(
|
|
data: PasswordChange,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
if not verify_password(data.old_password, current_user.password_hash):
|
|
log_action(db, action="change_password", user=current_user, request=request, target_type="user", target_id=current_user.id, detail="旧密码错误", status="failed")
|
|
raise HTTPException(status_code=400, detail="当前密码错误")
|
|
if data.old_password == data.new_password:
|
|
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
|
|
new_pwd_err = validate_password_strength(data.new_password)
|
|
if new_pwd_err:
|
|
raise HTTPException(status_code=400, detail=new_pwd_err)
|
|
current_user.password_hash = get_password_hash(data.new_password)
|
|
db.commit()
|
|
log_action(db, action="change_password", user=current_user, request=request, target_type="user", target_id=current_user.id, detail="修改密码成功")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/avatar", response_model=dict)
|
|
async def upload_avatar(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""上传用户头像,返回可访问的 URL。"""
|
|
content = await file.read()
|
|
settings = get_settings()
|
|
check = validate_upload(file, content=content, max_size=5 * 1024 * 1024)
|
|
# 仅允许图片
|
|
if check.suffix not in {"png", "jpg", "jpeg", "gif", "webp", "bmp"}:
|
|
raise HTTPException(status_code=415, detail="头像仅支持 png/jpg/jpeg/gif/webp/bmp 格式")
|
|
# 保存到 UPLOAD_DIR/avatars/
|
|
avatars_dir = os.path.join(settings.UPLOAD_DIR, "avatars")
|
|
os.makedirs(avatars_dir, exist_ok=True)
|
|
filename = f"user_{current_user.id}_{int(__import__('time').time())}.{check.suffix}"
|
|
save_path = os.path.join(avatars_dir, filename)
|
|
with open(save_path, "wb") as f:
|
|
f.write(content)
|
|
avatar_url = f"/uploads/avatars/{filename}"
|
|
current_user.avatar = avatar_url
|
|
db.commit()
|
|
log_action(db, action="upload_avatar", user=current_user, request=request, target_type="user", target_id=current_user.id, detail=f"上传头像 {filename}")
|
|
return {"success": True, "avatar": avatar_url}
|
|
|
|
|
|
def credit_benefit_payload(user: User, db: Session) -> dict:
|
|
account = ensure_credit_account(db, user)
|
|
daily_claimed = has_credit_transaction_today(db, user, "daily_checkin")
|
|
recent_checkins = (
|
|
db.query(CreditTransaction)
|
|
.filter(CreditTransaction.user_id == user.id, CreditTransaction.action == "daily_checkin")
|
|
.order_by(CreditTransaction.created_at.desc())
|
|
.limit(7)
|
|
.all()
|
|
)
|
|
return {
|
|
"credits": account.balance,
|
|
"daily_amount": DAILY_CHECKIN_CREDITS,
|
|
"daily_claimed": daily_claimed,
|
|
"next_claim_text": "明日可继续领取" if daily_claimed else "今日可领取",
|
|
"recent_checkins": recent_checkins,
|
|
}
|
|
|
|
|
|
@router.get("/credits/benefits", response_model=CreditBenefitOut)
|
|
def get_credit_benefits(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
return credit_benefit_payload(current_user, db)
|
|
|
|
|
|
@router.post("/credits/daily-checkin", response_model=CreditBenefitOut)
|
|
def claim_daily_checkin(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
grant_daily_checkin(db, current_user)
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
return credit_benefit_payload(current_user, db)
|
|
|
|
|
|
@router.get("/stats", response_model=ProfileStatsOut)
|
|
def get_profile_stats(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
user_id = current_user.id
|
|
account = ensure_credit_account(db, current_user)
|
|
|
|
coursewares = db.query(Courseware).filter(Courseware.user_id == user_id, Courseware.status != "archived").all()
|
|
animations = db.query(Animation).filter(Animation.user_id == user_id).all()
|
|
exercises = db.query(Exercise).filter(Exercise.user_id == user_id).all()
|
|
lesson_plans = db.query(LessonPlan).filter(LessonPlan.user_id == user_id).all()
|
|
essay_grades = db.query(EssayGrade).filter(EssayGrade.user_id == user_id).all()
|
|
exams = db.query(Exam).filter(Exam.user_id == user_id).all()
|
|
mind_maps = db.query(MindMap).filter(MindMap.user_id == user_id).all()
|
|
resources = db.query(Resource).filter(Resource.user_id == user_id, Resource.status == "active").all()
|
|
classroom_activities = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == user_id).all()
|
|
community_posts = db.query(Post).filter(Post.user_id == user_id).all()
|
|
|
|
works_by_type = {
|
|
"courseware": len(coursewares),
|
|
"animation": len(animations),
|
|
"exercise": len(exercises),
|
|
"lesson_plan": len(lesson_plans),
|
|
"essay_grade": len(essay_grades),
|
|
"exam": len(exams),
|
|
"mindmap": len(mind_maps),
|
|
}
|
|
resources_by_type = {key: 0 for key in ["courseware", "animation", "exercise", "lesson_plan", "exam", "other"]}
|
|
for resource in resources:
|
|
resources_by_type[resource.resource_type or "other"] = resources_by_type.get(resource.resource_type or "other", 0) + 1
|
|
|
|
published_refs = {item.content_ref for item in resources if item.content_ref}
|
|
published_refs.update(f"courseware:{item.id}" for item in coursewares if item.status == "published")
|
|
published_refs.update(f"animation:{item.id}" for item in animations if item.status == "published")
|
|
published_works = len(published_refs)
|
|
favorite_count = db.query(ResourceFavorite).filter(ResourceFavorite.user_id == user_id).count()
|
|
community_favorite_count = db.query(PostFavorite).filter(PostFavorite.user_id == user_id).count()
|
|
community_comment_count = db.query(Comment).filter(Comment.user_id == user_id).count()
|
|
|
|
def source_status(item, item_type: str, fallback: str = "") -> str:
|
|
if f"{item_type}:{item.id}" in published_refs:
|
|
return "published"
|
|
return fallback
|
|
|
|
def recent_work(item, item_type: str, subtitle: str = "", status: str = "") -> dict:
|
|
return {
|
|
"id": item.id,
|
|
"item_type": item_type,
|
|
"title": item.title,
|
|
"subtitle": subtitle,
|
|
"status": status,
|
|
"updated_at": item.updated_at,
|
|
}
|
|
|
|
classroom_type_labels = {
|
|
"poll": "课堂投票",
|
|
"qa": "即时问答",
|
|
"quiz": "随堂测验",
|
|
"checkin": "课堂签到",
|
|
"feedback": "学习反馈",
|
|
}
|
|
community_type_labels = {
|
|
"discussion": "案例",
|
|
"share": "模板",
|
|
"help": "需求",
|
|
}
|
|
|
|
recent_works = [
|
|
*[recent_work(item, "courseware", "课件", source_status(item, "courseware", item.status or "draft")) for item in coursewares],
|
|
*[recent_work(item, "animation", "动画", source_status(item, "animation", item.status or "draft")) for item in animations],
|
|
*[recent_work(item, "exercise", "互动练习", source_status(item, "exercise", "draft")) for item in exercises],
|
|
*[recent_work(item, "lesson_plan", "教案", source_status(item, "lesson_plan", "draft")) for item in lesson_plans],
|
|
*[recent_work(item, "essay_grade", "作文批改", "已批改") for item in essay_grades],
|
|
*[recent_work(item, "exam", "试卷", source_status(item, "exam", "draft")) for item in exams],
|
|
*[recent_work(item, "mindmap", "思维导图", "draft") for item in mind_maps],
|
|
]
|
|
recent_works.sort(key=lambda item: item["updated_at"] or datetime.min, reverse=True)
|
|
|
|
recent_resources = [
|
|
{
|
|
"id": item.id,
|
|
"item_type": item.resource_type or "other",
|
|
"title": item.title,
|
|
"subtitle": "公开" if item.is_public else "私密",
|
|
"status": item.status or "active",
|
|
"updated_at": item.updated_at,
|
|
}
|
|
for item in sorted(resources, key=lambda resource: resource.updated_at or datetime.min, reverse=True)[:5]
|
|
]
|
|
|
|
recent_interactions = [
|
|
*[
|
|
{
|
|
"id": item.id,
|
|
"item_type": "classroom_activity",
|
|
"title": item.title,
|
|
"subtitle": f"{classroom_type_labels.get(item.activity_type or '', '课堂活动')} · {len(item.responses or [])} 条提交",
|
|
"status": item.status or "draft",
|
|
"updated_at": item.updated_at,
|
|
}
|
|
for item in classroom_activities
|
|
],
|
|
*[
|
|
{
|
|
"id": item.id,
|
|
"item_type": "community_post",
|
|
"title": item.title,
|
|
"subtitle": f"{community_type_labels.get(item.post_type or '', '社区内容')} · {item.likes or 0} 收藏 · {item.comments_count or 0} 评论",
|
|
"status": "published",
|
|
"updated_at": item.updated_at,
|
|
}
|
|
for item in community_posts
|
|
],
|
|
]
|
|
recent_interactions.sort(key=lambda item: item["updated_at"] or datetime.min, reverse=True)
|
|
|
|
total_favorites = db.query(func.count(ResourceFavorite.id)).join(Resource).filter(
|
|
Resource.user_id == user_id,
|
|
Resource.status == "active",
|
|
).scalar() or 0
|
|
recent_credit_transactions = sorted(
|
|
list(current_user.credit_transactions or []),
|
|
key=lambda item: item.created_at or datetime.min,
|
|
reverse=True,
|
|
)[:8]
|
|
|
|
return {
|
|
"credits": account.balance,
|
|
"total_credits_granted": account.total_granted,
|
|
"total_credits_used": account.total_used,
|
|
"total_works": sum(works_by_type.values()),
|
|
"draft_works": max(sum(works_by_type.values()) - published_works, 0),
|
|
"published_works": published_works,
|
|
"published_resources": len(resources),
|
|
"public_resources": sum(1 for item in resources if item.is_public),
|
|
"private_resources": sum(1 for item in resources if not item.is_public),
|
|
"favorite_resources": favorite_count,
|
|
"total_views": sum(item.views or 0 for item in resources),
|
|
"total_downloads": sum(item.downloads or 0 for item in resources),
|
|
"total_favorites": total_favorites,
|
|
"classroom_activities": len(classroom_activities),
|
|
"active_classroom_activities": sum(1 for item in classroom_activities if item.status == "active"),
|
|
"classroom_responses": sum(len(item.responses or []) for item in classroom_activities),
|
|
"community_posts": len(community_posts),
|
|
"community_favorites": community_favorite_count,
|
|
"community_comments": community_comment_count,
|
|
"works_by_type": works_by_type,
|
|
"resources_by_type": resources_by_type,
|
|
"recent_works": recent_works[:5],
|
|
"recent_resources": recent_resources,
|
|
"recent_interactions": recent_interactions[:6],
|
|
"recent_credit_transactions": recent_credit_transactions,
|
|
}
|
|
|
|
|
|
@limiter.limit("3/minute")
|
|
@router.post("/send-code", response_model=dict)
|
|
def send_reset_code(
|
|
request: Request,
|
|
data: SendCodeRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""发送密码重置验证码。生产环境对接短信网关;开发环境返回 code 便于调试。"""
|
|
user = db.query(User).filter(User.phone == data.phone).first()
|
|
if not user:
|
|
# 不暴露手机号是否注册,统一返回成功
|
|
return {"success": True, "message": "验证码已发送"}
|
|
code = f"{secrets.randbelow(1000000):06d}"
|
|
expires = datetime.now() + timedelta(minutes=5)
|
|
# 使该手机号之前未消费的验证码失效
|
|
db.query(ResetCode).filter(
|
|
ResetCode.phone == data.phone, ResetCode.consumed == 0
|
|
).update({"consumed": 1}, synchronize_session=False)
|
|
db.add(ResetCode(phone=data.phone, code=code, expires_at=expires, consumed=0))
|
|
db.commit()
|
|
# 开发环境返回验证码;生产环境去掉 dev_code 字段
|
|
return {"success": True, "message": "验证码已发送", "dev_code": code}
|
|
|
|
|
|
@router.post("/reset-password", response_model=dict)
|
|
def reset_password(
|
|
request: Request,
|
|
data: ResetPasswordRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""通过验证码重置密码。"""
|
|
pwd_err = validate_password_strength(data.new_password)
|
|
if pwd_err:
|
|
raise HTTPException(status_code=400, detail=pwd_err)
|
|
record = db.query(ResetCode).filter(
|
|
ResetCode.phone == data.phone,
|
|
ResetCode.code == data.code,
|
|
ResetCode.consumed == 0,
|
|
).order_by(ResetCode.created_at.desc()).first()
|
|
if not record:
|
|
raise HTTPException(status_code=400, detail="验证码无效或已过期")
|
|
if record.expires_at < datetime.now():
|
|
record.consumed = 1
|
|
db.commit()
|
|
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
|
user = db.query(User).filter(User.phone == data.phone).first()
|
|
if not user:
|
|
raise HTTPException(status_code=400, detail="账号不存在")
|
|
user.password_hash = get_password_hash(data.new_password)
|
|
record.consumed = 1
|
|
db.commit()
|
|
log_action(db, action="reset_password", user=user, request=request, target_type="user", target_id=user.id, detail="通过验证码重置密码")
|
|
return {"success": True, "message": "密码重置成功"}
|