智教助手平台:完整初始化

- 前端: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:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+435
View File
@@ -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": "密码重置成功"}