智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
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": "密码重置成功"}
|
||||
Reference in New Issue
Block a user