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": "密码重置成功"}