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

- 前端: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
+10
View File
@@ -0,0 +1,10 @@
DATABASE_URL=sqlite:///./jiaoyu.db
REDIS_URL=redis://localhost:6379/0
AI_API_KEY=your-api-key-here
AI_API_BASE=https://api.openai.com/v1
AI_MODEL=gpt-4o
# 必填:JWT 签名密钥,生产环境请用 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 生成
SECRET_KEY=please-generate-a-random-secret-key-of-at-least-32-chars
CORS_ORIGINS=["http://localhost:5173"]
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p uploads
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+40
View File
@@ -0,0 +1,40 @@
# Alembic migration configuration
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite:///./jiaoyu.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+54
View File
@@ -0,0 +1,54 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from config import get_settings
from database import Base
import models # noqa: F401 ensures all models are loaded
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,28 @@
"""initial schema
Revision ID: 97e0aa47c003
Revises:
Create Date: 2026-07-21 11:38:09.510081
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '97e0aa47c003'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,44 @@
"""add audit logs table
Revision ID: a1b2c3d4e5f6
Revises: 97e0aa47c003
Create Date: 2026-07-21 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '97e0aa47c003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'audit_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('username', sa.String(length=100), nullable=False, server_default=''),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('target_type', sa.String(length=32), nullable=False, server_default=''),
sa.Column('target_id', sa.String(length=64), nullable=False, server_default=''),
sa.Column('detail', sa.Text(), nullable=False, server_default=''),
sa.Column('ip', sa.String(length=64), nullable=False, server_default=''),
sa.Column('user_agent', sa.String(length=255), nullable=False, server_default=''),
sa.Column('status', sa.String(length=16), nullable=False, server_default='success'),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_audit_logs_id', 'audit_logs', ['id'])
op.create_index('ix_audit_logs_user_id', 'audit_logs', ['user_id'])
op.create_index('ix_audit_logs_action', 'audit_logs', ['action'])
def downgrade() -> None:
op.drop_index('ix_audit_logs_action', table_name='audit_logs')
op.drop_index('ix_audit_logs_user_id', table_name='audit_logs')
op.drop_index('ix_audit_logs_id', table_name='audit_logs')
op.drop_table('audit_logs')
@@ -0,0 +1,39 @@
"""add mind maps table
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-07-22 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'mind_maps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('subject', sa.String(length=50), nullable=False, server_default='综合'),
sa.Column('nodes', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_mind_maps_id', 'mind_maps', ['id'])
op.create_index('ix_mind_maps_user_id', 'mind_maps', ['user_id'])
def downgrade() -> None:
op.drop_index('ix_mind_maps_user_id', table_name='mind_maps')
op.drop_index('ix_mind_maps_id', table_name='mind_maps')
op.drop_table('mind_maps')
@@ -0,0 +1,37 @@
"""add reset codes table
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-07-22 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'reset_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('phone', sa.String(length=20), nullable=False),
sa.Column('code', sa.String(length=6), nullable=False),
sa.Column('consumed', sa.Integer(), nullable=False, server_default='0'),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_reset_codes_id', 'reset_codes', ['id'])
op.create_index('ix_reset_codes_phone', 'reset_codes', ['phone'])
def downgrade() -> None:
op.drop_index('ix_reset_codes_phone', table_name='reset_codes')
op.drop_index('ix_reset_codes_id', table_name='reset_codes')
op.drop_table('reset_codes')
@@ -0,0 +1,41 @@
"""add notifications table
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-07-22 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = 'c3d4e5f6a7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'notifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('actor_id', sa.Integer(), nullable=True),
sa.Column('ntype', sa.String(length=32), nullable=False, server_default='comment'),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('content', sa.String(length=500), nullable=False, server_default=''),
sa.Column('link', sa.String(length=255), nullable=False, server_default=''),
sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('0')),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_notifications_id', 'notifications', ['id'])
op.create_index('ix_notifications_user_id', 'notifications', ['user_id'])
def downgrade() -> None:
op.drop_index('ix_notifications_user_id', table_name='notifications')
op.drop_index('ix_notifications_id', table_name='notifications')
op.drop_table('notifications')
+86
View File
@@ -0,0 +1,86 @@
import os
import logging
import secrets as _secrets
from pydantic_settings import BaseSettings
from pydantic import field_validator
from functools import lru_cache
logger = logging.getLogger(__name__)
# 已知的弱/默认密钥,实例化时会拒绝(防止源码泄露后被伪造 JWT)
_INSECURE_DEFAULTS = {
"",
"change-me-in-production",
"changeme",
"secret",
"your-secret-key",
}
class Settings(BaseSettings):
APP_NAME: str = "智教助手"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
DATABASE_URL: str = "sqlite:///./jiaoyu.db"
REDIS_URL: str = "redis://localhost:6379/0"
SECRET_KEY: str = "change-me-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
AI_API_KEY: str = ""
AI_API_BASE: str = "https://api.openai.com/v1"
AI_MODEL: str = "gpt-4o"
CORS_ORIGINS: list[str] = ["http://localhost:5173"]
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024
MAX_REQUEST_BODY_SIZE: int = 60 * 1024 * 1024 # global request body limit
@field_validator("SECRET_KEY")
@classmethod
def _validate_secret_key(cls, v: str) -> str:
"""在 .env 加载后执行:弱默认值在开发模式生成临时密钥,生产模式拒绝启动。"""
raw = (v or "").strip()
debug_env = str(os.getenv("DEBUG", "")).lower()
# DEBUG 字段可能已被 pydantic 解析;优先看环境变量原始值,回退看已解析的 DEBUG
is_debug = debug_env in ("", "1", "true", "yes", "on") if debug_env else True
# 若环境变量显式设了 DEBUG,用它;否则用 pydantic 解析后的值(默认 True
if debug_env:
is_debug = debug_env in ("1", "true", "yes", "on")
if raw in _INSECURE_DEFAULTS:
if is_debug:
generated = _secrets.token_urlsafe(48)
logger.warning(
"SECRET_KEY 未配置或为弱默认值,已生成临时开发密钥(仅本次运行有效)。"
"生产环境请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机字符串。"
)
return generated
raise RuntimeError(
"SECRET_KEY 未配置或为弱默认值,生产环境拒绝启动。"
"请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机字符串。"
)
if len(raw) < 32:
if is_debug:
logger.warning(
"SECRET_KEY 长度不足 32 字符(当前 %d),建议使用更长的随机字符串。", len(raw)
)
return raw
raise RuntimeError(
f"SECRET_KEY 长度不足 32 字符(当前 {len(raw)}),生产环境拒绝启动。"
)
return raw
class Config:
env_file = ".env"
extra = "ignore"
@lru_cache()
def get_settings() -> Settings:
return Settings()
+40
View File
@@ -0,0 +1,40 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from config import get_settings
settings = get_settings()
_is_sqlite = "sqlite" in settings.DATABASE_URL
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if _is_sqlite else {},
echo=settings.DEBUG,
)
# SQLite concurrency hardening: enable WAL mode + busy_timeout on every connection.
# WAL allows concurrent readers alongside a single writer, eliminating most
# "database is locked" errors under FastAPI's threaded request handling.
if _is_sqlite:
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_conn, conn_record):
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.execute("PRAGMA busy_timeout=10000")
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Binary file not shown.
Binary file not shown.
+91
View File
@@ -0,0 +1,91 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from starlette.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from services.limiter import limiter
from fastapi.staticfiles import StaticFiles
from config import get_settings
from database import engine, Base
import models # noqa: F401
from routers import auth, courseware, animation, exercise, lesson_plan, essay, ai, resource, community, exam, classroom, material, search, admin, mindmap, notification, chat
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
yield
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class BodySizeLimitMiddleware(BaseHTTPMiddleware):
"""Reject request bodies exceeding MAX_REQUEST_BODY_SIZE (default 60MB)."""
async def dispatch(self, request: StarletteRequest, call_next):
max_size = getattr(settings, "MAX_REQUEST_BODY_SIZE", 60 * 1024 * 1024)
cl = request.headers.get("content-length")
if cl and int(cl) > max_size:
return JSONResponse(
status_code=413,
content={"detail": f"请求体过大,最大允许 {max_size // (1024*1024)}MB"},
)
return await call_next(request)
app.add_middleware(BodySizeLimitMiddleware)
app.include_router(auth.router)
app.include_router(courseware.router)
app.include_router(animation.router)
app.include_router(exercise.router)
app.include_router(lesson_plan.router)
app.include_router(essay.router)
app.include_router(exam.router)
app.include_router(ai.router)
app.include_router(resource.router)
app.include_router(material.router)
app.include_router(community.router)
app.include_router(classroom.router)
app.include_router(search.router)
app.include_router(admin.router)
app.include_router(mindmap.router)
app.include_router(notification.router)
app.include_router(chat.router)
if os.path.exists(settings.UPLOAD_DIR):
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
@app.get("/api/health")
def health_check():
return {"status": "ok", "version": settings.APP_VERSION}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=settings.DEBUG)
+28
View File
@@ -0,0 +1,28 @@
from .user import User
from .courseware import Courseware, CoursewareShare
from .animation import Animation
from .exercise import Exercise, ExerciseAttempt
from .lesson_plan import LessonPlan
from .essay import EssayGrade
from .exam import Exam
from .resource import Resource, ResourceFavorite
from .material import Material
from .community import Post, Comment, PostFavorite
from .classroom import ClassroomActivity
from .credit import CreditAccount, CreditTransaction
from .audit import AuditLog
from .mindmap import MindMap
from .reset_code import ResetCode
from .notification import Notification
from .chat import ChatConversation, ChatMessage
__all__ = [
"User", "Courseware", "CoursewareShare", "Animation", "Exercise", "ExerciseAttempt",
"LessonPlan", "EssayGrade", "Exam", "Resource", "ResourceFavorite", "Material", "Post", "Comment", "PostFavorite", "ClassroomActivity",
"CreditAccount", "CreditTransaction",
"AuditLog",
"MindMap",
"ResetCode",
"Notification",
"ChatConversation", "ChatMessage",
]
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Animation(Base):
__tablename__ = "animations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
anim_type = Column(
SAEnum("math", "physics", "chemistry", "biology", "geography", "general", name="anim_type"),
default="general",
)
description = Column(Text, default="")
config = Column(JSON, default=dict)
thumbnail = Column(String(500), default="")
status = Column(SAEnum("draft", "published", name="anim_status"), default="draft")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="animations")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from database import Base
class AuditLog(Base):
"""安全审计日志:记录登录、权限变更、内容发布、敏感操作等。"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, nullable=True, index=True)
username = Column(String(100), default="")
action = Column(String(64), nullable=False, index=True)
target_type = Column(String(32), default="")
target_id = Column(String(64), default="")
detail = Column(Text, default="")
ip = Column(String(64), default="")
user_agent = Column(String(255), default="")
status = Column(String(16), default="success") # success / failed / denied
created_at = Column(DateTime, server_default=func.now())
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class ChatConversation(Base):
__tablename__ = "chat_conversations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
title = Column(String(200), nullable=False, default="新对话")
subject = Column(String(50), default="")
grade = Column(String(50), default="")
pinned = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="chat_conversations")
messages = relationship(
"ChatMessage",
back_populates="conversation",
order_by="ChatMessage.id",
lazy="selectin",
cascade="all, delete-orphan",
)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("chat_conversations.id"), nullable=False, index=True)
role = Column(String(20), nullable=False) # user / assistant / system
content = Column(Text, nullable=False)
tokens = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
conversation = relationship("ChatConversation", back_populates="messages")
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import relationship
from database import Base
class ClassroomActivity(Base):
__tablename__ = "classroom_activities"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
activity_type = Column(String(50), default="poll")
description = Column(Text, default="")
config = Column(JSON, default=dict)
responses = Column(JSON, default=list)
status = Column(String(20), default="draft")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User")
+54
View File
@@ -0,0 +1,54 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
content = Column(Text, default="")
post_type = Column(String(50), default="discussion")
tags = Column(JSON, default=list)
attachments = Column(JSON, default=list)
views = Column(Integer, default=0)
likes = Column(Integer, default=0)
comments_count = Column(Integer, default=0)
is_pinned = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="posts")
comments = relationship("Comment", back_populates="post", lazy="selectin", cascade="all, delete-orphan")
favorites = relationship("PostFavorite", back_populates="post", lazy="selectin", cascade="all, delete-orphan")
class Comment(Base):
__tablename__ = "comments"
id = Column(Integer, primary_key=True, index=True)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
parent_id = Column(Integer, ForeignKey("comments.id"), nullable=True)
likes = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
post = relationship("Post", back_populates="comments")
author = relationship("User")
class PostFavorite(Base):
__tablename__ = "post_favorites"
__table_args__ = (UniqueConstraint("user_id", "post_id", name="uq_post_favorite_user_post"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, index=True)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User")
post = relationship("Post", back_populates="favorites")
+46
View File
@@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Courseware(Base):
__tablename__ = "coursewares"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
subject = Column(String(50), default="")
grade = Column(String(50), default="")
description = Column(Text, default="")
content = Column(JSON, default=list)
cover_image = Column(String(500), default="")
status = Column(SAEnum("draft", "published", "archived", name="cw_status"), default="draft")
version = Column(Integer, default=1)
tags = Column(JSON, default=list)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="coursewares")
class CoursewareShare(Base):
__tablename__ = "courseware_shares"
id = Column(Integer, primary_key=True, index=True)
courseware_id = Column(Integer, ForeignKey("coursewares.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
token = Column(String(64), unique=True, nullable=False, index=True)
title = Column(String(200), nullable=False)
subject = Column(String(50), default="")
grade = Column(String(50), default="")
description = Column(Text, default="")
content = Column(JSON, default=list)
tags = Column(JSON, default=list)
views = Column(Integer, default=0)
status = Column(String(20), default="active")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
courseware = relationship("Courseware")
author = relationship("User")
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class CreditAccount(Base):
__tablename__ = "credit_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False, index=True)
balance = Column(Integer, default=130)
total_granted = Column(Integer, default=130)
total_used = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="credit_account")
class CreditTransaction(Base):
__tablename__ = "credit_transactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
amount = Column(Integer, nullable=False)
action = Column(String(50), default="")
description = Column(Text, default="")
balance_after = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="credit_transactions")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class EssayGrade(Base):
__tablename__ = "essay_grades"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
title = Column(String(200), nullable=False)
essay_text = Column(Text, default="")
grade_level = Column(String(50), default="初中")
essay_type = Column(String(50), default="记叙文")
total_score = Column(Integer, default=50)
result = Column(JSON, default=dict)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="essay_grades")
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Exam(Base):
__tablename__ = "exams"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
subject = Column(String(50), default="")
grade = Column(String(50), default="")
questions = Column(JSON, default=list)
answers = Column(JSON, default=list)
duration = Column(Integer, default=90)
total_score = Column(Integer, default=100)
difficulty = Column(String(20), default="medium")
knowledge_points = Column(JSON, default=list)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+44
View File
@@ -0,0 +1,44 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Exercise(Base):
__tablename__ = "exercises"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
exercise_type = Column(
SAEnum(
"game_snake", "game_match", "game_adventure",
"choice", "fill_blank", "true_false", "drag_sort", "matching",
name="exercise_type",
),
default="choice",
)
subject = Column(String(50), default="")
knowledge_points = Column(JSON, default=list)
questions = Column(JSON, default=list)
settings = Column(JSON, default=dict)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="exercises")
attempts = relationship("ExerciseAttempt", back_populates="exercise", lazy="selectin")
class ExerciseAttempt(Base):
__tablename__ = "exercise_attempts"
id = Column(Integer, primary_key=True, index=True)
exercise_id = Column(Integer, ForeignKey("exercises.id"), nullable=False)
student_name = Column(String(50), default="")
answers = Column(JSON, default=list)
score = Column(Integer, default=0)
total = Column(Integer, default=0)
duration = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
exercise = relationship("Exercise", back_populates="attempts")
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class LessonPlan(Base):
__tablename__ = "lesson_plans"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
subject = Column(String(50), default="")
grade = Column(String(50), default="")
objectives = Column(JSON, default=list)
key_points = Column(JSON, default=list)
difficulties = Column(JSON, default=list)
content = Column(JSON, default=dict)
duration = Column(Integer, default=45)
materials = Column(JSON, default=list)
homework = Column(JSON, default=list)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="lesson_plans")
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Material(Base):
__tablename__ = "materials"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
filename = Column(String(255), nullable=False)
title = Column(String(200), nullable=False)
material_type = Column(String(50), default="text")
subject = Column(String(50), default="")
grade = Column(String(50), default="")
tags = Column(JSON, default=list)
summary = Column(Text, default="")
char_count = Column(Integer, default=0)
size = Column(Integer, default=0)
source = Column(String(50), default="upload")
status = Column(SAEnum("active", "archived", name="material_status"), default="active")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="materials")
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class MindMap(Base):
__tablename__ = "mind_maps"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
title = Column(String(200), nullable=False)
subject = Column(String(50), default="综合")
nodes = Column(JSON, default=list)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="mind_maps")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Notification(Base):
"""站内通知:评论/收藏/系统消息。"""
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
actor_id = Column(Integer, nullable=True)
ntype = Column(String(32), nullable=False, default="comment")
title = Column(String(200), nullable=False)
content = Column(String(500), default="")
link = Column(String(255), default="")
is_read = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="notifications")
+16
View File
@@ -0,0 +1,16 @@
import datetime
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from database import Base
class ResetCode(Base):
"""密码重置验证码:绑定手机号,5 分钟过期。"""
__tablename__ = "reset_codes"
id = Column(Integer, primary_key=True, index=True)
phone = Column(String(20), nullable=False, index=True)
code = Column(String(6), nullable=False)
consumed = Column(Integer, default=0)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, server_default=func.now())
+46
View File
@@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Resource(Base):
__tablename__ = "resources"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
resource_type = Column(
SAEnum("courseware", "animation", "exercise", "lesson_plan", "exam", "other", name="res_type"),
default="other",
)
title = Column(String(200), nullable=False)
description = Column(Text, default="")
content_ref = Column(String(500), default="")
file_url = Column(String(500), default="")
cover_image = Column(String(500), default="")
tags = Column(JSON, default=list)
subject = Column(String(50), default="")
grade = Column(String(50), default="")
downloads = Column(Integer, default=0)
likes = Column(Integer, default=0)
views = Column(Integer, default=0)
is_public = Column(Integer, default=1)
status = Column(SAEnum("active", "archived", name="res_status"), default="active")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author = relationship("User", back_populates="resources")
favorites = relationship("ResourceFavorite", back_populates="resource", cascade="all, delete-orphan")
class ResourceFavorite(Base):
__tablename__ = "resource_favorites"
__table_args__ = (UniqueConstraint("user_id", "resource_id", name="uq_resource_favorite_user_resource"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
resource_id = Column(Integer, ForeignKey("resources.id"), nullable=False, index=True)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="resource_favorites")
resource = relationship("Resource", back_populates="favorites")
+36
View File
@@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, DateTime, Enum as SAEnum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
phone = Column(String(20), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
name = Column(String(50), nullable=False)
avatar = Column(String(500), default="")
subject = Column(String(50), default="")
school = Column(String(100), default="")
grade = Column(String(50), default="")
role = Column(SAEnum("teacher", "admin", name="user_role"), default="teacher")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
coursewares = relationship("Courseware", back_populates="author", lazy="selectin")
animations = relationship("Animation", back_populates="author", lazy="selectin")
exercises = relationship("Exercise", back_populates="author", lazy="selectin")
lesson_plans = relationship("LessonPlan", back_populates="author", lazy="selectin")
essay_grades = relationship("EssayGrade", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
resources = relationship("Resource", back_populates="author", lazy="selectin")
materials = relationship("Material", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
resource_favorites = relationship("ResourceFavorite", back_populates="user", lazy="selectin", cascade="all, delete-orphan")
posts = relationship("Post", back_populates="author", lazy="selectin")
credit_account = relationship("CreditAccount", back_populates="user", uselist=False, lazy="selectin", cascade="all, delete-orphan")
credit_transactions = relationship("CreditTransaction", back_populates="user", lazy="selectin", cascade="all, delete-orphan")
mind_maps = relationship("MindMap", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
notifications = relationship("Notification", back_populates="user", lazy="selectin", cascade="all, delete-orphan")
chat_conversations = relationship("ChatConversation", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
+22
View File
@@ -0,0 +1,22 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
bcrypt==4.0.1
python-multipart==0.0.20
httpx==0.28.1
redis==5.2.1
celery==5.4.0
openai==1.58.1
python-docx==1.1.2
pillow==11.1.0
python-pptx==1.0.2
aiofiles==24.1.0
python-dotenv==1.0.1
typing_extensions>=4.13.0
pypdf==5.1.0
openpyxl==3.1.5
slowapi==0.1.9
View File
+236
View File
@@ -0,0 +1,236 @@
from datetime import datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from models.animation import Animation
from models.courseware import Courseware
from models.credit import CreditAccount, CreditTransaction
from models.exercise import Exercise
from models.lesson_plan import LessonPlan
from models.material import Material
from models.resource import Resource, ResourceFavorite
from models.user import User
from models.audit import AuditLog
from schemas.admin import AdminDashboardOut, AdminGrantCredits, AdminUserOut, AdminUserUpdate
from schemas.resource import ResourceOut, ResourceAuthorOut
from services.auth import get_admin_user
from services.audit import log_action
from services.credits import grant_credits
router = APIRouter(prefix="/api/admin", tags=["管理后台"])
def _user_with_credits(user: User, db: Session) -> dict:
account = db.query(CreditAccount).filter(CreditAccount.user_id == user.id).first()
data = AdminUserOut.model_validate(user).model_dump()
data["credits"] = account.balance if account else 0
return data
@router.get("/dashboard", response_model=AdminDashboardOut)
def dashboard(current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)):
today = datetime.now().date()
today_start = datetime.combine(today, time.min)
total_users = db.query(func.count(User.id)).scalar() or 0
active_users = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
admin_users = db.query(func.count(User.id)).filter(User.role == "admin").scalar() or 0
new_users_today = db.query(func.count(User.id)).filter(User.created_at >= today_start).scalar() or 0
total_resources = db.query(func.count(Resource.id)).filter(Resource.status == "active").scalar() or 0
public_resources = db.query(func.count(Resource.id)).filter(Resource.status == "active", Resource.is_public == 1).scalar() or 0
total_coursewares = db.query(func.count(Courseware.id)).filter(Courseware.status != "archived").scalar() or 0
total_animations = db.query(func.count(Animation.id)).filter(Animation.status != "archived").scalar() or 0
total_exercises = db.query(func.count(Exercise.id)).scalar() or 0
total_lesson_plans = db.query(func.count(LessonPlan.id)).scalar() or 0
total_views = db.query(func.coalesce(func.sum(Resource.views), 0)).scalar() or 0
total_downloads = db.query(func.coalesce(func.sum(Resource.downloads), 0)).scalar() or 0
total_favorites = db.query(func.count(ResourceFavorite.id)).scalar() or 0
total_credits_used = db.query(func.coalesce(func.sum(CreditAccount.total_used), 0)).scalar() or 0
exam_count = 0
try:
from models.exam import Exam
exam_count = db.query(func.count(Exam.id)).scalar() or 0
except Exception:
pass
return AdminDashboardOut(
total_users=total_users,
active_users=active_users,
admin_users=admin_users,
new_users_today=new_users_today,
total_resources=total_resources,
public_resources=public_resources,
total_coursewares=total_coursewares,
total_animations=total_animations,
total_exercises=total_exercises,
total_lesson_plans=total_lesson_plans,
total_exams=exam_count,
total_views=total_views,
total_downloads=total_downloads,
total_favorites=total_favorites,
total_credits_used=total_credits_used,
)
@router.get("/users", response_model=list[AdminUserOut])
def list_users(
keyword: str = Query("", description="搜索手机号或姓名"),
role: str = Query("", description="按角色筛选"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(User)
if keyword:
like = f"%{keyword}%"
query = query.filter((User.phone.contains(like)) | (User.name.contains(like)))
if role:
query = query.filter(User.role == role)
users = query.order_by(User.created_at.desc()).offset(skip).limit(limit).all()
return [_user_with_credits(u, db) for u in users]
@router.put("/users/{user_id}", response_model=AdminUserOut)
def update_user(
user_id: int,
data: AdminUserUpdate,
request: Request,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
changes = []
if data.role is not None:
if data.role not in {"teacher", "admin"}:
raise HTTPException(status_code=400, detail="角色无效")
changes.append(f"role:{user.role}->{data.role}")
user.role = data.role
if data.is_active is not None:
changes.append(f"active:{user.is_active}->{data.is_active}")
user.is_active = data.is_active
if data.name is not None:
changes.append(f"name:{user.name}->{data.name}")
user.name = data.name
db.commit()
db.refresh(user)
log_action(db, action="admin_update_user", user=current_user, request=request, target_type="user", target_id=user.id, detail=f"修改用户 {user.name or user.phone or user.id}{', '.join(changes) or '无变更'}")
return _user_with_credits(user, db)
@router.post("/users/{user_id}/grant-credits", response_model=AdminUserOut)
def grant_user_credits(
user_id: int,
data: AdminGrantCredits,
request: Request,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
grant_credits(db, user, data.amount, action="admin_grant", description=f"管理员 {current_user.name} 发放 {data.amount} 积分")
db.commit()
db.refresh(user)
log_action(db, action="admin_grant_credits", user=current_user, request=request, target_type="user", target_id=user.id, detail=f"{user.name or user.phone or user.id} 发放 {data.amount} 积分")
return _user_with_credits(user, db)
@router.get("/resources", response_model=list[ResourceOut])
def list_all_resources(
keyword: str = Query(""),
resource_type: str = Query(""),
status: str = Query("active"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
from sqlalchemy import or_
query = db.query(Resource)
if status:
query = query.filter(Resource.status == status)
if resource_type:
query = query.filter(Resource.resource_type == resource_type)
if keyword:
like = f"%{keyword}%"
query = query.filter(or_(Resource.title.contains(like), Resource.description.contains(like)))
resources = query.order_by(Resource.created_at.desc()).offset(skip).limit(limit).all()
result = []
for res in resources:
out = ResourceOut.model_validate(res)
out.is_favorited = False
result.append(out)
return result
@router.put("/resources/{resource_id}/moderate", response_model=dict)
def moderate_resource(
resource_id: int,
request: Request,
status: str = Query(..., description="active 或 archived"),
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
if status not in {"active", "archived"}:
raise HTTPException(status_code=400, detail="状态无效")
res = db.query(Resource).filter(Resource.id == resource_id).first()
if not res:
raise HTTPException(status_code=404, detail="资源不存在")
prev = res.status
res.status = status
db.commit()
log_action(db, action="admin_moderate_resource", user=current_user, request=request, target_type="resource", target_id=res.id, detail=f"资源《{res.title}》状态 {prev} -> {status}")
return {"success": True, "status": res.status}
@router.get("/audit-logs", response_model=list[dict])
def list_audit_logs(
action: str | None = None,
status: str | None = None,
user_id: int | None = None,
keyword: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
"""查询安全审计日志(仅管理员)。"""
from sqlalchemy import or_
q = db.query(AuditLog)
if action:
q = q.filter(AuditLog.action == action)
if status:
q = q.filter(AuditLog.status == status)
if user_id:
q = q.filter(AuditLog.user_id == user_id)
if keyword:
like = f"%{keyword}%"
q = q.filter(or_(AuditLog.username.contains(like), AuditLog.detail.contains(like), AuditLog.ip.contains(like)))
rows = q.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
return [
{
"id": r.id,
"user_id": r.user_id,
"username": r.username,
"action": r.action,
"target_type": r.target_type,
"target_id": r.target_id,
"detail": r.detail,
"ip": r.ip,
"user_agent": r.user_agent,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
+297
View File
@@ -0,0 +1,297 @@
import re
from io import BytesIO
from urllib.parse import quote
from docx import Document
from pptx import Presentation
from PIL import Image
from pypdf import PdfReader
from openpyxl import load_workbook
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import StreamingResponse
from models.material import Material
from models.user import User
from schemas.ai import EssayGradeRequest, ExamExportRequest, ExamGenerateRequest, HtmlExportRequest
from services.limiter import limiter
from services.auth import get_current_user
from services.audit import log_action
from services.upload_security import validate_upload, sanitize_filename
from services.ai_service import AIService
from services.credits import credits_payload, spend_credits
from database import get_db
from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/ai", tags=["AI能力"])
ai_service = AIService()
@router.post("/essay-grade", response_model=dict)
@limiter.limit("10/minute")
async def grade_essay(
request: Request,
data: EssayGradeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "essay_grade", "作文批改")
db.commit()
result = await ai_service.grade_essay(
essay_text=data.essay_text, grade_level=data.grade_level,
essay_type=data.essay_type, total_score=data.total_score,
)
return {"success": True, "data": result, "credits": credits_payload(account, "essay_grade")}
@router.post("/exam-generate", response_model=dict)
@limiter.limit("10/minute")
async def generate_exam(
request: Request,
data: ExamGenerateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "exam_generate", f"智能命题:{data.subject}")
db.commit()
result = await ai_service.generate_exam(
subject=data.subject, grade=data.grade,
knowledge_points=data.knowledge_points, difficulty=data.difficulty,
question_types=data.question_types, count=data.count, total_score=data.total_score,
)
return {"success": True, "data": result, "credits": credits_payload(account, "exam_generate")}
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "export"
return f"{stem[:80]}{suffix}"
def _summarize_text(text: str, limit: int = 4000) -> str:
normalized = re.sub(r"[ \t]+", " ", text.replace("\r\n", "\n")).strip()
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
return normalized[:limit]
def _title_from_filename(filename: str) -> str:
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
title = re.sub(r"[_-]+", " ", stem).strip() or "教学素材"
return title[:200]
def _decode_text(content: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "gb18030", "gbk"):
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
return content.decode("utf-8", errors="ignore")
def _extract_docx(content: bytes) -> str:
document = Document(BytesIO(content))
paragraphs = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
table_lines = []
for table in document.tables:
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if cells:
table_lines.append(" | ".join(cells))
return "\n".join([*paragraphs, *table_lines])
def _extract_pptx(content: bytes) -> str:
presentation = Presentation(BytesIO(content))
lines = []
for index, slide in enumerate(presentation.slides, start=1):
slide_lines = []
for shape in slide.shapes:
text = getattr(shape, "text", "").strip()
if text:
slide_lines.append(text)
if slide_lines:
lines.append(f"{index} 页:\n" + "\n".join(slide_lines))
return "\n\n".join(lines)
def _extract_pdf(content: bytes) -> str:
reader = PdfReader(BytesIO(content))
pages = []
for index, page in enumerate(reader.pages, start=1):
text = (page.extract_text() or "").strip()
if text:
pages.append(f"{index} 页:\n{text}")
return "\n\n".join(pages)
def _extract_xlsx(content: bytes) -> str:
workbook = load_workbook(BytesIO(content), data_only=True, read_only=True)
lines = []
for sheet in workbook.worksheets:
row_lines = []
for row in sheet.iter_rows(values_only=True):
cells = [str(cell).strip() for cell in row if cell is not None and str(cell).strip()]
if cells:
row_lines.append(" | ".join(cells))
if row_lines:
lines.append(f"工作表 {sheet.title}:\n" + "\n".join(row_lines[:200]))
return "\n\n".join(lines)
async def _describe_image(content: bytes, filename: str) -> str:
try:
with Image.open(BytesIO(content)) as image:
width, height = image.size
mode = image.mode
except Exception:
width, height, mode = 0, 0, ""
meta = f"图片文件:{filename}"
if width and height:
meta += f";尺寸:{width}×{height}px"
if mode:
meta += f";色彩模式:{mode}"
# Try AI vision OCR first; fall back to metadata-only description
try:
ocr_text = await ai_service.ocr_image(content, filename)
if ocr_text and "[无法识别文字内容]" not in ocr_text:
return f"{meta}\n\n【图片文字识别结果】\n{ocr_text}"
except Exception as exc:
import logging
logging.getLogger(__name__).warning("图片OCR失败,使用元数据描述: %s", exc)
return (
f"{meta}\n"
"这是一份拍照/图片教学素材,可能包含教材页、题目、板书、图表、实验现象或课堂场景。"
"当前系统已保存图片素材类型,可将其带入课件、动画、练习、教案或命题流程;"
"生成时请教师在提示词中补充图片中的关键文字、题干或知识点,以便产出更准确。"
)
@router.post("/materials/parse", response_model=dict)
@limiter.limit("20/minute")
async def parse_material(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
content = await file.read()
check = validate_upload(file, content=content, max_size=50 * 1024 * 1024)
filename = check.filename
suffix = check.suffix
media_type = check.media_type
try:
if media_type.startswith("image/"):
text = await _describe_image(content, filename)
material_type = "image"
elif suffix == "docx":
text = _extract_docx(content)
material_type = "docx"
elif suffix == "pptx":
text = _extract_pptx(content)
material_type = "pptx"
elif suffix == "pdf":
text = _extract_pdf(content)
material_type = "pdf"
elif suffix in {"xlsx", "xls"}:
text = _extract_xlsx(content)
material_type = "xlsx"
elif suffix in {"txt", "md", "csv", "json"} or media_type.startswith("text/"):
text = _decode_text(content)
material_type = suffix or "text"
else:
raise HTTPException(status_code=415, detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片")
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail=f"材料解析失败:{exc}") from exc
summary = _summarize_text(text)
account = spend_credits(db, current_user, "material_parse", f"解析材料:{filename}")
material = Material(
user_id=current_user.id,
filename=filename,
title=_title_from_filename(filename),
material_type=material_type,
summary=summary,
char_count=len(text),
size=len(content),
source="upload",
)
db.add(material)
db.commit()
db.refresh(material)
log_action(db, action="material_parse", user=current_user, request=request, target_type="material", target_id=material.id, detail=f"解析 {filename}{material_type}, {len(content)}B")
return {
"success": True,
"credits": credits_payload(account, "material_parse"),
"data": {
"material_id": material.id,
"filename": filename,
"title": material.title,
"material_type": material_type,
"size": len(content),
"summary": summary,
"char_count": len(text),
},
}
@router.post("/export/html")
def export_html(data: HtmlExportRequest, current_user: User = Depends(get_current_user)):
content = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{data.title}</title>
</head>
<body>
{data.html}
</body>
</html>
"""
filename = _safe_filename(data.title, ".html")
return StreamingResponse(
BytesIO(content.encode("utf-8")),
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.post("/export/exam-docx")
def export_exam_docx(data: ExamExportRequest, current_user: User = Depends(get_current_user)):
document = Document()
document.add_heading(data.title or "试卷", level=1)
meta = " / ".join(part for part in [data.subject, data.grade] if part)
if meta:
document.add_paragraph(meta)
for idx, question in enumerate(data.questions, start=1):
q_type = question.get("type") or question.get("question_type") or "题目"
score = question.get("score")
heading = f"{idx}. [{q_type}]"
if score:
heading += f"{score}分)"
document.add_paragraph(heading)
document.add_paragraph(str(question.get("content") or question.get("question") or ""))
options = question.get("options") or []
if isinstance(options, list):
for option in options:
document.add_paragraph(str(option), style=None)
if data.answers:
document.add_page_break()
document.add_heading("参考答案", level=1)
for idx, answer in enumerate(data.answers, start=1):
document.add_paragraph(f"{idx}. {answer}")
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(data.title, ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
+188
View File
@@ -0,0 +1,188 @@
import re
from html import escape
from io import BytesIO
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.animation import Animation
from models.resource import Resource
from models.user import User
from schemas.animation import AnimationGenerate, AnimationCreate, AnimationUpdate, AnimationOut
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user, get_optional_current_user
from services.ai_service import AIService
router = APIRouter(prefix="/api/animations", tags=["教学动画"])
ai_service = AIService()
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教学动画"
return f"{stem[:80]}{suffix}"
def _animation_or_404(anim_id: int, current_user: User | None, db: Session) -> Animation:
anim = db.query(Animation).filter(Animation.id == anim_id).first()
if not anim:
raise HTTPException(status_code=404, detail="动画不存在")
if (not current_user or anim.user_id != current_user.id) and not has_public_resource(db, anim_id):
raise HTTPException(status_code=403, detail="无权访问该动画")
return anim
def _build_animation_html(anim: Animation) -> str:
config = anim.config or {}
body = config.get("html") or config.get("content") or ""
if not isinstance(body, str) or not body.strip():
raise HTTPException(status_code=400, detail="当前动画没有可导出的 HTML")
title = escape(anim.title or config.get("title") or "教学动画")
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
html, body {{ margin: 0; width: 100%; min-height: 100%; background: #f6faf7; }}
body {{ display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }}
.animation-stage {{ width: min(1120px, 100%); aspect-ratio: 16 / 9; min-height: 540px; background: #fff; border: 1px solid #dce8de; border-radius: 12px; overflow: hidden; box-shadow: 0 18px 40px rgba(17, 69, 52, .10); }}
</style>
</head>
<body>
<main class="animation-stage">
{body}
</main>
</body>
</html>
"""
def has_public_resource(db: Session, anim_id: int) -> bool:
return db.query(Resource).filter(
Resource.content_ref == f"animation:{anim_id}",
Resource.status == "active",
Resource.is_public == 1,
).first() is not None
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_animation(
request: Request,
data: AnimationGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "animation_generate", f"生成教学动画:{data.prompt[:80]}")
db.commit()
result = await ai_service.generate_animation(prompt=data.prompt, anim_type=data.anim_type)
return {"success": True, "data": result, "credits": credits_payload(account, "animation_generate")}
@router.post("/", response_model=AnimationOut, status_code=201)
def create_animation(data: AnimationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
anim = Animation(user_id=current_user.id, **data.model_dump())
db.add(anim)
db.commit()
db.refresh(anim)
return anim
@router.get("/", response_model=list[AnimationOut])
def list_animations(
anim_type: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Animation).filter(Animation.user_id == current_user.id)
if anim_type:
query = query.filter(Animation.anim_type == anim_type)
return query.order_by(Animation.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{anim_id}", response_model=AnimationOut)
def get_animation(
anim_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _animation_or_404(anim_id, current_user, db)
@router.get("/{anim_id}/export-html")
def export_animation_html(
anim_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
anim = _animation_or_404(anim_id, current_user, db)
content = _build_animation_html(anim)
filename = _safe_filename(f"{anim.title or '教学动画'}-动画", ".html")
return StreamingResponse(
BytesIO(content.encode("utf-8")),
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{anim_id}", response_model=AnimationOut)
def update_animation(
anim_id: int, data: AnimationUpdate,
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
):
anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
if not anim:
raise HTTPException(status_code=404, detail="动画不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(anim, key, value)
db.commit()
db.refresh(anim)
return anim
@router.post("/{anim_id}/remix", response_model=AnimationOut, status_code=201)
def remix_animation(
anim_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(Animation).filter(Animation.id == anim_id).first()
if not source:
raise HTTPException(status_code=404, detail="动画不存在")
if source.user_id != current_user.id and not has_public_resource(db, anim_id):
raise HTTPException(status_code=403, detail="无权改编该动画")
clone = Animation(
user_id=current_user.id,
title=f"{source.title}(改编)",
anim_type=source.anim_type or "general",
description=source.description or "",
config=source.config or {},
thumbnail=source.thumbnail or "",
status="draft",
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{anim_id}", status_code=204)
def delete_animation(anim_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
if not anim:
raise HTTPException(status_code=404, detail="动画不存在")
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"animation:{anim_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.delete(anim)
db.commit()
+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": "密码重置成功"}
+184
View File
@@ -0,0 +1,184 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from database import get_db
from models.chat import ChatConversation, ChatMessage
from models.user import User
from schemas.chat import ConversationCreate, ConversationRename, ChatSend, MessageOut, ConversationOut
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user
from services.audit import log_action
from services.ai_service import AIService
router = APIRouter(prefix="/api/chat", tags=["AI教学助手"])
ai_service = AIService()
# 历史上下文窗口:最近 N 轮(user+assistant 计为 2 条),避免 token 超限
MAX_HISTORY_TURNS = 12
MAX_TITLE_LEN = 60
def _ensure_owned(db: Session, conversation_id: int, user: User) -> ChatConversation:
item = db.query(ChatConversation).filter(
ChatConversation.id == conversation_id,
ChatConversation.user_id == user.id,
).first()
if not item:
raise HTTPException(status_code=404, detail="对话不存在")
return item
def _derive_title(text: str) -> str:
cleaned = " ".join(text.split())
return (cleaned[:MAX_TITLE_LEN] + "") if len(cleaned) > MAX_TITLE_LEN else (cleaned or "新对话")
@router.get("/conversations", response_model=list[ConversationOut])
def list_conversations(
keyword: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(30, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(ChatConversation).filter(ChatConversation.user_id == current_user.id)
if keyword:
query = query.filter(ChatConversation.title.contains(keyword))
return (
query.order_by(ChatConversation.pinned.desc(), ChatConversation.updated_at.desc())
.offset(skip).limit(limit).all()
)
@router.post("/conversations", response_model=ConversationOut, status_code=201)
def create_conversation(
data: ConversationCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = ChatConversation(
user_id=current_user.id,
title=(data.title or "新对话").strip() or "新对话",
subject=data.subject,
grade=data.grade,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.get("/conversations/{conversation_id}", response_model=ConversationOut)
def get_conversation(
conversation_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return _ensure_owned(db, conversation_id, current_user)
@router.put("/conversations/{conversation_id}", response_model=ConversationOut)
def rename_conversation(
conversation_id: int,
data: ConversationRename,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = _ensure_owned(db, conversation_id, current_user)
item.title = data.title.strip() or item.title
if data.pinned is not None:
item.pinned = data.pinned
db.commit()
db.refresh(item)
return item
@router.delete("/conversations/{conversation_id}", status_code=204)
def delete_conversation(
conversation_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = _ensure_owned(db, conversation_id, current_user)
db.delete(item)
db.commit()
return None
@router.post("/conversations/{conversation_id}/messages", response_model=dict)
@limiter.limit("20/minute")
async def send_message(
request: Request,
conversation_id: int,
data: ChatSend,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
conversation = _ensure_owned(db, conversation_id, current_user)
account = spend_credits(db, current_user, "chat_generate", "AI教学助手对话")
db.add(ChatMessage(
conversation_id=conversation.id,
role="user",
content=data.content,
))
db.flush()
history = (
db.query(ChatMessage)
.filter(ChatMessage.conversation_id == conversation.id)
.order_by(ChatMessage.id.desc())
.limit(MAX_HISTORY_TURNS)
.all()
)
history = list(reversed(history))
history_payload = [{"role": m.role, "content": m.content} for m in history[:-1]] if history else []
try:
reply = await ai_service.chat_teaching(
history=history_payload,
user_message=data.content,
subject=data.subject or conversation.subject,
grade=data.grade or conversation.grade,
)
except RuntimeError as exc:
msg = str(exc)
if "CONNECT" in msg or "TIMEOUT" in msg:
reply = (
"无法连接到 AI 服务,请检查网络或稍后重试。\n"
"你的问题我已记录,服务恢复后重新发送即可继续对话。"
)
elif "EMPTY" in msg:
reply = (
"AI 模型本次未返回内容(推理模型可能因思考超时导致空回复),请稍后重试或换一种问法。"
)
else:
reply = (
"当前 AI 服务暂时不可用,请稍后重试。我已经记录了你的问题,"
"服务恢复后再次发送即可继续对话。"
)
assistant_msg = ChatMessage(
conversation_id=conversation.id,
role="assistant",
content=reply,
)
db.add(assistant_msg)
first_message = len(conversation.messages) <= 2
if conversation.title == "新对话" or first_message:
conversation.title = _derive_title(data.content)
db.commit()
db.refresh(assistant_msg)
log_action(
db, action="chat_send", user=current_user, request=request,
target_type="chat_conversation", target_id=conversation.id,
detail=f"AI助手对话({len(data.content)}字)",
)
return {
"success": True,
"data": MessageOut.model_validate(assistant_msg).model_dump(),
"title": conversation.title,
"credits": credits_payload(account, "chat_generate"),
}
+513
View File
@@ -0,0 +1,513 @@
import re
from collections import Counter
from datetime import datetime, timezone
from io import BytesIO
from html import escape
from urllib.parse import quote
from docx import Document
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from database import get_db
from models.classroom import ClassroomActivity
from models.user import User
from schemas.classroom import (
ClassroomActivityCreate,
ClassroomAnalysisOut,
ClassroomActivityOut,
ClassroomActivityUpdate,
ClassroomResponseCreate,
)
from services.auth import get_current_user
router = APIRouter(prefix="/api/classroom", tags=["课堂工具"])
STOP_WORDS = {
"这个", "我们", "老师", "学生", "因为", "所以", "可以", "需要", "没有", "已经",
"the", "and", "that", "with", "this", "from", "have", "will",
}
def _format_answer(answer) -> str:
if isinstance(answer, str):
return answer.strip()
if answer is None:
return ""
return str(answer)
def _percent(count: int, total: int) -> int:
return round((count / total) * 100) if total else 0
def _extract_keywords(text: str) -> list[dict]:
words = re.findall(r"[\u4e00-\u9fa5]{2,}|[a-zA-Z0-9]{2,}", text)
counter = Counter(word for word in words if word.lower() not in STOP_WORDS)
return [{"keyword": word, "count": count} for word, count in counter.most_common(10)]
def _infer_correct_answer(activity: ClassroomActivity) -> str:
config = activity.config or {}
for key in ("answer", "correct_answer", "correctAnswer"):
value = config.get(key)
if value:
return str(value).strip()
return ""
def _build_mastery_level(activity_type: str, response_count: int, accuracy: int | None, option_stats: list[dict]) -> str:
if response_count <= 0:
return "暂无数据"
if accuracy is not None:
if accuracy >= 85:
return "整体掌握较好"
if accuracy >= 60:
return "基础掌握,仍需巩固"
return "薄弱点明显,建议重点讲评"
if option_stats:
top = max(option_stats, key=lambda item: item["count"])
if top["percent"] >= 70:
return f"倾向集中:{top['option']}"
if top["percent"] >= 45:
return f"主要倾向:{top['option']}"
return "分布分散,需要追问原因"
if activity_type in {"qa", "feedback"}:
return "已收集开放反馈"
return "已收集课堂数据"
def _build_diagnosis(
activity: ClassroomActivity,
response_count: int,
participant_count: int,
option_stats: list[dict],
keywords: list[dict],
accuracy: int | None,
) -> list[str]:
if response_count <= 0:
return ["尚未收到学生提交,建议先发布活动并提醒学生扫码参与。"]
diagnosis = [f"本次共收到 {response_count} 条提交,覆盖 {participant_count} 名学生。"]
if accuracy is not None:
diagnosis.append(f"按正确答案统计,当前正确率为 {accuracy}%。")
if option_stats:
top = max(option_stats, key=lambda item: item["count"])
diagnosis.append(f"选择最多的是“{top['option']}”,占比 {top['percent']}%。")
weak_options = [item for item in option_stats if item["count"] and item["percent"] >= 20 and item is not top]
if weak_options:
names = "".join(item["option"] for item in weak_options[:3])
diagnosis.append(f"仍有较多学生选择“{names}”,建议追问选择原因。")
if keywords:
diagnosis.append("开放回答中高频词包括:" + "".join(item["keyword"] for item in keywords[:5]) + "")
if activity.activity_type == "checkin":
diagnosis.append("该活动更适合作为参与记录,可结合后续测验或反馈判断掌握情况。")
return diagnosis
def _build_teaching_suggestions(
activity: ClassroomActivity,
response_count: int,
option_stats: list[dict],
accuracy: int | None,
keywords: list[dict],
) -> list[str]:
if response_count <= 0:
return ["先用投放链接收集至少 5 条反馈,再生成更可靠的讲评建议。"]
suggestions = []
if accuracy is not None and accuracy < 60:
suggestions.append("用 5 分钟回到概念源头,先讲清关键定义或公式来源,再做同类例题。")
suggestions.append("安排 3 道低门槛变式题,确认学生能独立完成基本步骤。")
elif accuracy is not None and accuracy < 85:
suggestions.append("挑选一个典型错选项进行对比讲评,让学生说出错误思路。")
suggestions.append("追加 2 到 3 道变式练习,覆盖易错条件和表达规范。")
elif accuracy is not None:
suggestions.append("整体掌握较好,可进入拓展任务或让学生互相解释解题依据。")
elif option_stats:
top = max(option_stats, key=lambda item: item["count"])
suggestions.append(f"围绕“{top['option']}”这个主流反馈追问原因,确认学生是理解到位还是凭感觉选择。")
suggestions.append("让不同选择的学生各举一个理由,形成板书对比后再归纳共识。")
else:
suggestions.append("把高频回答聚类成 2 到 3 类,在下一轮讲评中分别回应。")
if keywords:
suggestions.append(f"优先处理“{keywords[0]['keyword']}”相关问题,并设计一个即时追问。")
suggestions.append("课末再投放一次简短反馈,比较讲评前后的变化。")
return suggestions[:5]
def _build_followup_prompt(activity: ClassroomActivity, analysis: dict) -> str:
question = analysis.get("question") or activity.title
diagnosis = "".join(analysis.get("diagnosis") or [])
material_context = str((activity.config or {}).get("material_context") or "").strip()
material_part = f"参考素材:{material_context}" if material_context else ""
return (
f"基于课堂数据回收《{activity.title}》生成一套二次巩固练习。"
f"回收题目:{question}"
f"{material_part}"
f"学情诊断:{diagnosis}"
"要求题目有梯度,覆盖高频错误点,附答案和解析。"
)
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂诊断报告"
return f"{stem[:80]}{suffix}"
def _excel_cell(value, cell_type: str = "String") -> str:
if value is None:
value = ""
if isinstance(value, datetime):
value = value.strftime("%Y-%m-%d %H:%M:%S")
text = escape(str(value), quote=False)
return f"<Cell><Data ss:Type=\"{cell_type}\">{text}</Data></Cell>"
def _excel_row(values: list) -> str:
return "<Row>" + "".join(_excel_cell(value) for value in values) + "</Row>"
def _excel_sheet(name: str, rows: list[list]) -> str:
safe_name = escape(name[:31] or "Sheet", quote=True)
body = "\n".join(_excel_row(row) for row in rows)
return f"<Worksheet ss:Name=\"{safe_name}\"><Table>{body}</Table></Worksheet>"
def _build_activity_workbook(activity: ClassroomActivity, analysis: dict) -> bytes:
responses = list(activity.responses or [])
detail_rows = [
["活动ID", "活动标题", "活动类型", "学生姓名", "回答", "提交时间", "来源"],
*[
[
activity.id,
activity.title,
activity.activity_type,
res.get("student_name") or "匿名",
_format_answer(res.get("answer")),
res.get("submitted_at") or "",
(res.get("meta") or {}).get("source", ""),
]
for res in responses
],
]
stats_rows = [
["指标", "数值"],
["提交数", analysis["response_count"]],
["参与学生数", analysis["participant_count"]],
["掌握水平", analysis["mastery_level"]],
["正确率", "" if analysis["accuracy"] is None else f"{analysis['accuracy']}%"],
["回收题目", analysis["question"]],
]
if analysis["option_stats"]:
stats_rows.extend([[], ["选项", "人数", "占比"]])
stats_rows.extend([[item["option"], item["count"], f"{item['percent']}%"] for item in analysis["option_stats"]])
if analysis["keywords"]:
stats_rows.extend([[], ["关键词", "次数"]])
stats_rows.extend([[item["keyword"], item["count"]] for item in analysis["keywords"]])
diagnosis_rows = [
["诊断结论"],
*[[item] for item in analysis["diagnosis"]],
[],
["讲评建议"],
*[[item] for item in analysis["teaching_suggestions"]],
[],
["二次练习提示词"],
[analysis["followup_prompt"]],
]
workbook = f"""<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
{_excel_sheet("提交明细", detail_rows)}
{_excel_sheet("统计汇总", stats_rows)}
{_excel_sheet("诊断建议", diagnosis_rows)}
</Workbook>
"""
return workbook.encode("utf-8")
def _analysis_payload(activity: ClassroomActivity) -> dict:
responses = list(activity.responses or [])
response_count = len(responses)
participants = {
str(res.get("student_name") or "匿名").strip() or "匿名"
for res in responses
}
participant_count = len(participants)
answers = [_format_answer(res.get("answer")) for res in responses]
answer_text = " ".join(answers)
config = activity.config or {}
options = [str(option).strip() for option in config.get("options", []) if str(option).strip()]
option_stats = []
if options:
counter = Counter(answers)
option_stats = [
{"option": option, "count": counter.get(option, 0), "percent": _percent(counter.get(option, 0), response_count)}
for option in options
]
correct_answer = _infer_correct_answer(activity)
accuracy = None
if correct_answer and response_count:
accuracy = _percent(sum(1 for answer in answers if answer == correct_answer), response_count)
common_answers = [answer for answer, _ in Counter(answer for answer in answers if answer).most_common(6)]
keywords = _extract_keywords(answer_text)
mastery_level = _build_mastery_level(activity.activity_type, response_count, accuracy, option_stats)
diagnosis = _build_diagnosis(activity, response_count, participant_count, option_stats, keywords, accuracy)
teaching_suggestions = _build_teaching_suggestions(activity, response_count, option_stats, accuracy, keywords)
payload = {
"activity_id": activity.id,
"title": activity.title,
"activity_type": activity.activity_type,
"question": config.get("question") or activity.description or activity.title,
"response_count": response_count,
"participant_count": participant_count,
"option_stats": option_stats,
"keywords": keywords,
"common_answers": common_answers,
"mastery_level": mastery_level,
"accuracy": accuracy,
"diagnosis": diagnosis,
"teaching_suggestions": teaching_suggestions,
"generated_at": datetime.now(timezone.utc),
}
payload["followup_prompt"] = _build_followup_prompt(activity, payload)
return payload
def _public_activity_payload(activity: ClassroomActivity) -> ClassroomActivity:
config = dict(activity.config or {})
for key in ("answer", "correct_answer", "correctAnswer", "material_context"):
config.pop(key, None)
setattr(activity, "config", config)
return activity
def _add_bullets(document: Document, items: list[str]) -> None:
for item in items:
document.add_paragraph(str(item), style="List Bullet")
@router.post("/activities", response_model=ClassroomActivityOut, status_code=201)
def create_activity(
data: ClassroomActivityCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = ClassroomActivity(user_id=current_user.id, **data.model_dump())
db.add(activity)
db.commit()
db.refresh(activity)
return activity
@router.get("/activities", response_model=list[ClassroomActivityOut])
def list_activities(
activity_type: str = "",
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == current_user.id)
if activity_type:
query = query.filter(ClassroomActivity.activity_type == activity_type)
return query.order_by(ClassroomActivity.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/activities/{activity_id}", response_model=ClassroomActivityOut)
def get_activity(
activity_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(
ClassroomActivity.id == activity_id,
ClassroomActivity.user_id == current_user.id,
).first()
if not activity:
raise HTTPException(status_code=404, detail="课堂活动不存在")
return activity
@router.get("/activities/{activity_id}/analysis", response_model=ClassroomAnalysisOut)
def analyze_activity(
activity_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(
ClassroomActivity.id == activity_id,
ClassroomActivity.user_id == current_user.id,
).first()
if not activity:
raise HTTPException(status_code=404, detail="课堂活动不存在")
return _analysis_payload(activity)
@router.get("/activities/{activity_id}/analysis/export")
def export_activity_analysis(
activity_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(
ClassroomActivity.id == activity_id,
ClassroomActivity.user_id == current_user.id,
).first()
if not activity:
raise HTTPException(status_code=404, detail="课堂活动不存在")
analysis = _analysis_payload(activity)
document = Document()
document.add_heading(f"课堂学情诊断报告:{activity.title}", level=1)
document.add_paragraph(f"活动类型:{activity.activity_type}")
document.add_paragraph(f"回收题目:{analysis['question']}")
document.add_paragraph(f"生成时间:{analysis['generated_at'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
document.add_heading("核心指标", level=2)
document.add_paragraph(f"提交数:{analysis['response_count']}")
document.add_paragraph(f"参与学生数:{analysis['participant_count']}")
document.add_paragraph(f"掌握水平:{analysis['mastery_level']}")
if analysis["accuracy"] is not None:
document.add_paragraph(f"正确率:{analysis['accuracy']}%")
if analysis["option_stats"]:
document.add_heading("选项分布", level=2)
table = document.add_table(rows=1, cols=3)
table.style = "Table Grid"
hdr = table.rows[0].cells
hdr[0].text = "选项"
hdr[1].text = "人数"
hdr[2].text = "占比"
for item in analysis["option_stats"]:
row = table.add_row().cells
row[0].text = item["option"]
row[1].text = str(item["count"])
row[2].text = f"{item['percent']}%"
if analysis["keywords"]:
document.add_heading("高频关键词", level=2)
document.add_paragraph("".join(f"{item['keyword']}({item['count']})" for item in analysis["keywords"]))
if analysis["common_answers"]:
document.add_heading("常见回答", level=2)
_add_bullets(document, analysis["common_answers"])
document.add_heading("诊断结论", level=2)
_add_bullets(document, analysis["diagnosis"])
document.add_heading("讲评建议", level=2)
_add_bullets(document, analysis["teaching_suggestions"])
document.add_heading("二次练习提示词", level=2)
document.add_paragraph(analysis["followup_prompt"])
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(f"{activity.title}-学情诊断报告", ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.get("/activities/{activity_id}/responses/export")
def export_activity_responses(
activity_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(
ClassroomActivity.id == activity_id,
ClassroomActivity.user_id == current_user.id,
).first()
if not activity:
raise HTTPException(status_code=404, detail="课堂活动不存在")
analysis = _analysis_payload(activity)
buffer = BytesIO(_build_activity_workbook(activity, analysis))
filename = _safe_filename(f"{activity.title}-课堂数据回收", ".xls")
return StreamingResponse(
buffer,
media_type="application/vnd.ms-excel",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.get("/public/activities/{activity_id}", response_model=ClassroomActivityOut)
def get_public_activity(
activity_id: int,
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(ClassroomActivity.id == activity_id).first()
if not activity or activity.status not in {"active", "closed"}:
raise HTTPException(status_code=404, detail="课堂活动不存在")
return _public_activity_payload(activity)
@router.put("/activities/{activity_id}", response_model=ClassroomActivityOut)
def update_activity(
activity_id: int,
data: ClassroomActivityUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(
ClassroomActivity.id == activity_id,
ClassroomActivity.user_id == current_user.id,
).first()
if not activity:
raise HTTPException(status_code=404, detail="课堂活动不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(activity, key, value)
db.commit()
db.refresh(activity)
return activity
@router.post("/activities/{activity_id}/responses", response_model=dict, status_code=201)
def submit_response(
activity_id: int,
data: ClassroomResponseCreate,
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(ClassroomActivity.id == activity_id).first()
if not activity or activity.status == "archived":
raise HTTPException(status_code=404, detail="课堂活动不存在")
if activity.status != "active":
raise HTTPException(status_code=409, detail="课堂活动未发布或已结束")
responses = list(activity.responses or [])
responses.append({
"student_name": data.student_name,
"answer": data.answer,
"meta": data.meta,
"submitted_at": datetime.now(timezone.utc).isoformat(),
})
activity.responses = responses
db.commit()
return {"success": True, "count": len(responses)}
@router.delete("/activities/{activity_id}", status_code=204)
def delete_activity(
activity_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
activity = db.query(ClassroomActivity).filter(
ClassroomActivity.id == activity_id,
ClassroomActivity.user_id == current_user.id,
).first()
if not activity:
raise HTTPException(status_code=404, detail="课堂活动不存在")
db.delete(activity)
db.commit()
+210
View File
@@ -0,0 +1,210 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.community import Post, Comment, PostFavorite
from models.user import User
from schemas.community import PostCreate, PostUpdate, PostOut, CommentCreate, CommentOut
from services.auth import get_current_user, get_optional_current_user
from routers.notification import push_notification
router = APIRouter(prefix="/api/community", tags=["教师社区"])
def _sync_post_favorite_count(post: Post, db: Session) -> None:
post.likes = db.query(PostFavorite).filter(PostFavorite.post_id == post.id).count()
def _serialize_post(post: Post, current_user: User | None, db: Session) -> Post:
is_favorited = False
if current_user:
is_favorited = db.query(PostFavorite).filter(
PostFavorite.user_id == current_user.id,
PostFavorite.post_id == post.id,
).first() is not None
setattr(post, "is_favorited", is_favorited)
setattr(post, "author", post.author)
return post
@router.post("/posts", response_model=PostOut, status_code=201)
def create_post(data: PostCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
post = Post(user_id=current_user.id, **data.model_dump())
db.add(post)
db.commit()
db.refresh(post)
return _serialize_post(post, current_user, db)
@router.get("/posts", response_model=list[PostOut])
def list_posts(
post_type: Optional[str] = None,
keyword: Optional[str] = None,
scope: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
query = db.query(Post)
if scope in {"mine", "favorite"} and not current_user:
raise HTTPException(status_code=401, detail="请先登录")
if scope == "mine":
query = query.filter(Post.user_id == current_user.id)
elif scope == "favorite":
query = query.join(PostFavorite, PostFavorite.post_id == Post.id).filter(
PostFavorite.user_id == current_user.id,
)
if post_type:
query = query.filter(Post.post_type == post_type)
if keyword:
query = query.filter(or_(
Post.title.contains(keyword),
Post.content.contains(keyword),
))
posts = query.order_by(Post.is_pinned.desc(), Post.created_at.desc()).offset(skip).limit(limit).all()
return [_serialize_post(post, current_user, db) for post in posts]
@router.get("/posts/ranking", response_model=list[PostOut])
def ranking_posts(
limit: int = Query(5, ge=1, le=20),
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
score = Post.views + Post.likes * 10 + Post.comments_count * 3
posts = (
db.query(Post)
.order_by(Post.is_pinned.desc(), score.desc(), Post.created_at.desc())
.limit(limit)
.all()
)
return [_serialize_post(post, current_user, db) for post in posts]
@router.get("/posts/{post_id}", response_model=PostOut)
def get_post(
post_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
post.views = (post.views or 0) + 1
db.commit()
db.refresh(post)
return _serialize_post(post, current_user, db)
@router.put("/posts/{post_id}", response_model=PostOut)
def update_post(
post_id: int,
data: PostUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(post, key, value)
db.commit()
db.refresh(post)
return _serialize_post(post, current_user, db)
@router.post("/posts/{post_id}/like", response_model=dict)
def like_post(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
favorite = db.query(PostFavorite).filter(
PostFavorite.user_id == current_user.id,
PostFavorite.post_id == post_id,
).first()
if not favorite:
db.add(PostFavorite(user_id=current_user.id, post_id=post_id))
db.flush()
push_notification(db, user_id=post.user_id, actor_id=current_user.id, ntype="like", title=f"{current_user.name} 收藏了你的帖子", content=post.title[:60], link=f"/community/{post_id}")
_sync_post_favorite_count(post, db)
db.commit()
return {"success": True, "likes": post.likes, "is_favorited": True}
@router.delete("/posts/{post_id}/like", response_model=dict)
def unlike_post(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
favorite = db.query(PostFavorite).filter(
PostFavorite.user_id == current_user.id,
PostFavorite.post_id == post_id,
).first()
if favorite:
db.delete(favorite)
db.flush()
_sync_post_favorite_count(post, db)
db.commit()
return {"success": True, "likes": post.likes, "is_favorited": False}
@router.get("/posts/{post_id}/comments", response_model=list[CommentOut])
def list_comments(post_id: int, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db)):
return db.query(Comment).filter(Comment.post_id == post_id).order_by(Comment.created_at).offset(skip).limit(limit).all()
@router.post("/posts/{post_id}/comments", response_model=CommentOut, status_code=201)
def create_comment(
post_id: int, data: CommentCreate,
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
):
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
comment = Comment(post_id=post_id, user_id=current_user.id, **data.model_dump())
db.add(comment)
post.comments_count = (post.comments_count or 0) + 1
push_notification(db, user_id=post.user_id, actor_id=current_user.id, ntype="comment", title=f"{current_user.name} 评论了你的帖子", content=post.title[:60], link=f"/community/{post_id}")
db.commit()
db.refresh(comment)
return comment
@router.delete("/posts/{post_id}/comments/{comment_id}", status_code=204)
def delete_comment(
post_id: int,
comment_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
comment = db.query(Comment).filter(Comment.id == comment_id, Comment.post_id == post_id).first()
if not comment:
raise HTTPException(status_code=404, detail="评论不存在")
if comment.user_id != current_user.id and post.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除该评论")
db.delete(comment)
post.comments_count = max((post.comments_count or 0) - 1, 0)
db.commit()
@router.delete("/posts/{post_id}", status_code=204)
def delete_post(post_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
db.delete(post)
db.commit()
+317
View File
@@ -0,0 +1,317 @@
import html
import re
import secrets
from io import BytesIO
from urllib.parse import quote
from docx import Document
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.courseware import Courseware, CoursewareShare
from models.resource import Resource
from models.user import User
from schemas.courseware import CoursewareCreate, CoursewareAIGenerate, CoursewareShareOut, CoursewareUpdate, CoursewareOut
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user, get_optional_current_user
from services.ai_service import AIService
router = APIRouter(prefix="/api/coursewares", tags=["课件管理"])
ai_service = AIService()
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课件"
return f"{stem[:80]}{suffix}"
def _plain_text_from_html(raw_html: str) -> str:
text = str(raw_html or "")
text = re.sub(r"<(script|style)[\s\S]*?</\1>", " ", text, flags=re.IGNORECASE)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</(p|div|section|article|li|h[1-6]|tr)>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
text = re.sub(r"[ \t\u00a0]+", " ", text)
text = re.sub(r"\n\s+", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _courseware_or_404(
courseware_id: int,
current_user: User | None,
db: Session,
) -> Courseware:
cw = db.query(Courseware).filter(Courseware.id == courseware_id).first()
if not cw or cw.status == "archived":
raise HTTPException(status_code=404, detail="课件不存在")
if cw.status != "published" and (not current_user or cw.user_id != current_user.id):
raise HTTPException(status_code=403, detail="无权访问该课件")
return cw
def _add_if_present(document: Document, label: str, value: str | None) -> None:
if value:
document.add_paragraph(f"{label}{value}")
def _share_payload(share: CoursewareShare, request: Request | None = None) -> dict:
data = CoursewareShareOut.model_validate(share).model_dump()
data["share_url"] = f"/preview/share/{share.token}"
return data
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_courseware(
request: Request,
data: CoursewareAIGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "courseware_generate", f"生成互动课件:{data.prompt[:80]}")
db.commit()
result = await ai_service.generate_courseware(
prompt=data.prompt, subject=data.subject, grade=data.grade, page_count=data.page_count,
aspect_ratio=data.aspect_ratio,
)
return {"success": True, "data": result, "credits": credits_payload(account, "courseware_generate")}
@router.post("/", response_model=CoursewareOut, status_code=201)
def create_courseware(data: CoursewareCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
cw = Courseware(user_id=current_user.id, **data.model_dump())
db.add(cw)
db.commit()
db.refresh(cw)
return cw
@router.get("/", response_model=list[CoursewareOut])
def list_coursewares(
subject: Optional[str] = None,
grade: Optional[str] = None,
status: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Courseware).filter(Courseware.user_id == current_user.id)
if subject:
query = query.filter(Courseware.subject == subject)
if grade:
query = query.filter(Courseware.grade == grade)
if status:
query = query.filter(Courseware.status == status)
else:
query = query.filter(Courseware.status != "archived")
return query.order_by(Courseware.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/shares/mine/list", response_model=list[CoursewareShareOut])
def list_my_courseware_shares(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
shares = (
db.query(CoursewareShare)
.filter(CoursewareShare.user_id == current_user.id, CoursewareShare.status == "active")
.order_by(CoursewareShare.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [_share_payload(share, request) for share in shares]
@router.delete("/shares/mine/{share_id}", status_code=204)
def revoke_courseware_share(
share_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
share = db.query(CoursewareShare).filter(
CoursewareShare.id == share_id,
CoursewareShare.user_id == current_user.id,
CoursewareShare.status == "active",
).first()
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
share.status = "revoked"
db.commit()
@router.get("/shares/{token}", response_model=CoursewareShareOut)
def get_courseware_share(
token: str,
request: Request,
db: Session = Depends(get_db),
):
share = db.query(CoursewareShare).filter(
CoursewareShare.token == token,
CoursewareShare.status == "active",
).first()
if not share:
raise HTTPException(status_code=404, detail="分享不存在或已失效")
share.views = (share.views or 0) + 1
db.commit()
db.refresh(share)
return _share_payload(share, request)
@router.get("/{courseware_id}", response_model=CoursewareOut)
def get_courseware(
courseware_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _courseware_or_404(courseware_id, current_user, db)
@router.post("/{courseware_id}/share", response_model=CoursewareShareOut, status_code=201)
def create_courseware_share(
courseware_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
cw = db.query(Courseware).filter(
Courseware.id == courseware_id,
Courseware.user_id == current_user.id,
Courseware.status != "archived",
).first()
if not cw:
raise HTTPException(status_code=404, detail="课件不存在")
share = db.query(CoursewareShare).filter(
CoursewareShare.courseware_id == courseware_id,
CoursewareShare.user_id == current_user.id,
CoursewareShare.status == "active",
).first()
if not share:
share = CoursewareShare(
courseware_id=courseware_id,
user_id=current_user.id,
token=secrets.token_urlsafe(18),
)
db.add(share)
share.title = cw.title
share.subject = cw.subject or ""
share.grade = cw.grade or ""
share.description = cw.description or ""
share.content = cw.content or []
share.tags = list(cw.tags or [])
share.status = "active"
db.commit()
db.refresh(share)
return _share_payload(share, request)
@router.get("/{courseware_id}/export-docx")
def export_courseware_docx(
courseware_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
cw = _courseware_or_404(courseware_id, current_user, db)
document = Document()
document.add_heading(cw.title or "互动课件讲义", level=1)
meta_parts = [part for part in [cw.subject, cw.grade] if part]
if meta_parts:
document.add_paragraph(" / ".join(meta_parts))
_add_if_present(document, "课件说明", cw.description)
if cw.tags:
document.add_paragraph("标签:" + "".join(str(tag) for tag in cw.tags if str(tag).strip()))
pages = cw.content or []
document.add_paragraph(f"页数:{len(pages)}")
for index, page in enumerate(pages, start=1):
if not isinstance(page, dict):
continue
page_title = str(page.get("title") or f"{index}").strip()
document.add_heading(f"{index} 页:{page_title}", level=2)
page_type = page.get("type")
if page_type:
document.add_paragraph(f"页面类型:{page_type}")
text = _plain_text_from_html(page.get("content") or "")
document.add_paragraph(text or "该页暂无可提取文本。")
notes = str(page.get("notes") or "").strip()
if notes:
document.add_paragraph(f"教师备注:{notes}")
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(f"{cw.title or '课件'}-讲义", ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{courseware_id}", response_model=CoursewareOut)
def update_courseware(
courseware_id: int, data: CoursewareUpdate,
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
):
cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
if not cw:
raise HTTPException(status_code=404, detail="课件不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(cw, key, value)
db.commit()
db.refresh(cw)
return cw
@router.post("/{courseware_id}/remix", response_model=CoursewareOut, status_code=201)
def remix_courseware(
courseware_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(Courseware).filter(Courseware.id == courseware_id).first()
if not source or source.status == "archived":
raise HTTPException(status_code=404, detail="课件不存在")
if source.status != "published" and source.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权改编该课件")
clone = Courseware(
user_id=current_user.id,
title=f"{source.title}(改编)",
subject=source.subject or "",
grade=source.grade or "",
description=source.description or "",
content=source.content or [],
cover_image=source.cover_image or "",
tags=list(source.tags or []),
status="draft",
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{courseware_id}", status_code=204)
def delete_courseware(courseware_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
if not cw:
raise HTTPException(status_code=404, detail="课件不存在")
cw.status = "archived"
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"courseware:{courseware_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.commit()
+83
View File
@@ -0,0 +1,83 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from database import get_db
from models.essay import EssayGrade
from models.user import User
from schemas.essay import EssayGradeCreate, EssayGradeOut
from services.auth import get_current_user
from services.ai_service import AIService
from services.credits import spend_credits
router = APIRouter(prefix="/api/essay-grades", tags=["作文批改"])
ai_service = AIService()
@router.post("/", response_model=EssayGradeOut, status_code=201)
async def create_essay_grade(
data: EssayGradeCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Submit an essay for AI grading; the result is persisted automatically."""
account = spend_credits(db, current_user, "essay_grade", f"作文批改:{data.title[:40]}")
db.commit()
result = await ai_service.grade_essay(
essay_text=data.essay_text, grade_level=data.grade_level,
essay_type=data.essay_type, total_score=data.total_score,
)
item = EssayGrade(
user_id=current_user.id,
title=data.title,
essay_text=data.essay_text,
grade_level=data.grade_level,
essay_type=data.essay_type,
total_score=data.total_score,
result=result,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.get("/", response_model=list[EssayGradeOut])
def list_essay_grades(
keyword: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(EssayGrade).filter(EssayGrade.user_id == current_user.id)
if keyword:
query = query.filter(EssayGrade.title.contains(keyword))
return query.order_by(EssayGrade.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{grade_id}", response_model=EssayGradeOut)
def get_essay_grade(
grade_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(EssayGrade).filter(EssayGrade.id == grade_id, EssayGrade.user_id == current_user.id).first()
if not item:
raise HTTPException(status_code=404, detail="批改记录不存在")
return item
@router.delete("/{grade_id}", status_code=204)
def delete_essay_grade(
grade_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(EssayGrade).filter(EssayGrade.id == grade_id, EssayGrade.user_id == current_user.id).first()
if not item:
raise HTTPException(status_code=404, detail="批改记录不存在")
db.delete(item)
db.commit()
return None
+198
View File
@@ -0,0 +1,198 @@
import re
from io import BytesIO
from urllib.parse import quote
from docx import Document
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from database import get_db
from models.exam import Exam
from models.resource import Resource
from models.user import User
from schemas.exam import ExamCreate, ExamOut, ExamUpdate
from services.auth import get_current_user, get_optional_current_user
router = APIRouter(prefix="/api/exams", tags=["智能命题"])
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "试卷"
return f"{stem[:80]}{suffix}"
def _as_list(value) -> list:
if isinstance(value, list):
return value
if value in (None, ""):
return []
return [value]
def _exam_or_404(exam_id: int, current_user: User | None, db: Session) -> Exam:
exam = db.query(Exam).filter(Exam.id == exam_id).first()
if not exam:
raise HTTPException(status_code=404, detail="试卷不存在")
if (not current_user or exam.user_id != current_user.id) and not has_public_resource(db, exam_id):
raise HTTPException(status_code=403, detail="无权访问该试卷")
return exam
def has_public_resource(db: Session, exam_id: int) -> bool:
return db.query(Resource).filter(
Resource.content_ref == f"exam:{exam_id}",
Resource.status == "active",
Resource.is_public == 1,
).first() is not None
@router.post("/", response_model=ExamOut, status_code=201)
def create_exam(data: ExamCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
exam = Exam(user_id=current_user.id, **data.model_dump())
db.add(exam)
db.commit()
db.refresh(exam)
return exam
@router.get("/", response_model=list[ExamOut])
def list_exams(
subject: str = "",
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Exam).filter(Exam.user_id == current_user.id)
if subject:
query = query.filter(Exam.subject == subject)
return query.order_by(Exam.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{exam_id}", response_model=ExamOut)
def get_exam(
exam_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _exam_or_404(exam_id, current_user, db)
@router.get("/{exam_id}/export-docx")
def export_exam_docx(
exam_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
exam = _exam_or_404(exam_id, current_user, db)
document = Document()
document.add_heading(exam.title or "智能试卷", level=1)
meta = [
exam.subject,
exam.grade,
f"{exam.duration or 90}分钟",
f"总分{exam.total_score or 100}",
f"难度:{exam.difficulty or 'medium'}",
]
if exam.knowledge_points:
meta.append("知识点:" + "".join(str(item) for item in exam.knowledge_points if str(item).strip()))
document.add_paragraph(" / ".join(str(part) for part in meta if part))
for index, question in enumerate(exam.questions or [], start=1):
if not isinstance(question, dict):
document.add_paragraph(f"{index}. {question}")
continue
q_type = question.get("type") or question.get("question_type") or "题目"
score = question.get("score")
heading = f"{index}. [{q_type}]"
if score:
heading += f"{score}分)"
document.add_paragraph(heading)
document.add_paragraph(str(question.get("content") or question.get("question") or question.get("stem") or ""))
options = _as_list(question.get("options"))
for option_index, option in enumerate(options):
prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
document.add_paragraph(f"{prefix}. {option}")
answers = exam.answers or [question.get("answer") for question in exam.questions or [] if isinstance(question, dict)]
if answers:
document.add_page_break()
document.add_heading("参考答案与解析", level=1)
for index, question in enumerate(exam.questions or [], start=1):
answer = answers[index - 1] if index - 1 < len(answers) else ""
analysis = question.get("analysis") or question.get("explanation") if isinstance(question, dict) else ""
document.add_paragraph(f"{index}. 答案:{answer}")
if analysis:
document.add_paragraph(f"解析:{analysis}")
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(f"{exam.title or '智能试卷'}-试卷", ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{exam_id}", response_model=ExamOut)
def update_exam(
exam_id: int,
data: ExamUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first()
if not exam:
raise HTTPException(status_code=404, detail="试卷不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(exam, key, value)
db.commit()
db.refresh(exam)
return exam
@router.post("/{exam_id}/remix", response_model=ExamOut, status_code=201)
def remix_exam(
exam_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(Exam).filter(Exam.id == exam_id).first()
if not source:
raise HTTPException(status_code=404, detail="试卷不存在")
if source.user_id != current_user.id and not has_public_resource(db, exam_id):
raise HTTPException(status_code=403, detail="无权改编该试卷")
clone = Exam(
user_id=current_user.id,
title=f"{source.title}(改编)",
subject=source.subject or "",
grade=source.grade or "",
questions=list(source.questions or []),
answers=list(source.answers or []),
duration=source.duration or 90,
total_score=source.total_score or 100,
difficulty=source.difficulty or "medium",
knowledge_points=list(source.knowledge_points or []),
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{exam_id}", status_code=204)
def delete_exam(exam_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first()
if not exam:
raise HTTPException(status_code=404, detail="试卷不存在")
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"exam:{exam_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.delete(exam)
db.commit()
+248
View File
@@ -0,0 +1,248 @@
import re
from io import BytesIO
from urllib.parse import quote
from docx import Document
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.exercise import Exercise, ExerciseAttempt
from models.resource import Resource
from models.user import User
from schemas.exercise import ExerciseCreate, ExerciseAIGenerate, ExerciseAttemptCreate, ExerciseAttemptOut, ExerciseOut, ExerciseUpdate
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user, get_optional_current_user
from services.ai_service import AIService
router = APIRouter(prefix="/api/exercises", tags=["互动练习"])
ai_service = AIService()
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂练习"
return f"{stem[:80]}{suffix}"
def _as_list(value) -> list:
if isinstance(value, list):
return value
if value in (None, ""):
return []
return [value]
def _exercise_or_404(exercise_id: int, current_user: User | None, db: Session) -> Exercise:
ex = db.query(Exercise).filter(Exercise.id == exercise_id).first()
if not ex:
raise HTTPException(status_code=404, detail="练习不存在")
if (not current_user or ex.user_id != current_user.id) and not has_public_resource(db, exercise_id):
raise HTTPException(status_code=403, detail="无权访问该练习")
return ex
def has_public_resource(db: Session, exercise_id: int) -> bool:
return db.query(Resource).filter(
Resource.content_ref == f"exercise:{exercise_id}",
Resource.status == "active",
Resource.is_public == 1,
).first() is not None
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_exercise(
request: Request,
data: ExerciseAIGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "exercise_generate", f"生成互动练习:{data.prompt[:80]}")
db.commit()
result = await ai_service.generate_exercise(
prompt=data.prompt, exercise_type=data.exercise_type,
subject=data.subject, knowledge_points=data.knowledge_points, count=data.count,
)
return {"success": True, "data": result, "credits": credits_payload(account, "exercise_generate")}
@router.post("/", response_model=ExerciseOut, status_code=201)
def create_exercise(data: ExerciseCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
ex = Exercise(user_id=current_user.id, **data.model_dump())
db.add(ex)
db.commit()
db.refresh(ex)
return ex
@router.get("/", response_model=list[ExerciseOut])
def list_exercises(
exercise_type: Optional[str] = None,
subject: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Exercise).filter(Exercise.user_id == current_user.id)
if exercise_type:
query = query.filter(Exercise.exercise_type == exercise_type)
if subject:
query = query.filter(Exercise.subject == subject)
return query.order_by(Exercise.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{exercise_id}", response_model=ExerciseOut)
def get_exercise(
exercise_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _exercise_or_404(exercise_id, current_user, db)
@router.get("/{exercise_id}/export-docx")
def export_exercise_docx(
exercise_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
ex = _exercise_or_404(exercise_id, current_user, db)
document = Document()
document.add_heading(ex.title or "课堂练习", level=1)
meta_parts = [ex.subject, ex.exercise_type, f"{len(ex.questions or [])}"]
if ex.knowledge_points:
meta_parts.append("知识点:" + "".join(str(item) for item in ex.knowledge_points if str(item).strip()))
document.add_paragraph(" / ".join(str(part) for part in meta_parts if part))
for index, question in enumerate(ex.questions or [], start=1):
if not isinstance(question, dict):
document.add_paragraph(f"{index}. {question}")
continue
q_type = question.get("type") or ex.exercise_type or "练习"
difficulty = question.get("difficulty")
heading = f"{index}. [{q_type}]"
if difficulty:
heading += f"{difficulty}"
document.add_paragraph(heading)
document.add_paragraph(str(question.get("question") or question.get("content") or question.get("stem") or ""))
options = _as_list(question.get("options"))
for option_index, option in enumerate(options):
prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
document.add_paragraph(f"{prefix}. {option}")
answer = question.get("answer")
if answer not in (None, ""):
document.add_paragraph(f"答案:{answer}")
explanation = question.get("explanation") or question.get("analysis")
if explanation:
document.add_paragraph(f"解析:{explanation}")
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(f"{ex.title or '课堂练习'}-练习", ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{exercise_id}", response_model=ExerciseOut)
def update_exercise(
exercise_id: int,
data: ExerciseUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
if not ex:
raise HTTPException(status_code=404, detail="练习不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(ex, key, value)
db.commit()
db.refresh(ex)
return ex
@router.post("/{exercise_id}/remix", response_model=ExerciseOut, status_code=201)
def remix_exercise(
exercise_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(Exercise).filter(Exercise.id == exercise_id).first()
if not source:
raise HTTPException(status_code=404, detail="练习不存在")
if source.user_id != current_user.id and not has_public_resource(db, exercise_id):
raise HTTPException(status_code=403, detail="无权改编该练习")
clone = Exercise(
user_id=current_user.id,
title=f"{source.title}(改编)",
exercise_type=source.exercise_type or "choice",
subject=source.subject or "",
knowledge_points=list(source.knowledge_points or []),
questions=list(source.questions or []),
settings=dict(source.settings or {}),
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{exercise_id}", status_code=204)
def delete_exercise(exercise_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
if not ex:
raise HTTPException(status_code=404, detail="练习不存在")
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"exercise:{exercise_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.delete(ex)
db.commit()
@router.post("/{exercise_id}/attempt", response_model=dict, status_code=201)
def submit_attempt(exercise_id: int, data: ExerciseAttemptCreate, db: Session = Depends(get_db)):
exercise = db.query(Exercise).filter(Exercise.id == exercise_id).first()
if not exercise:
raise HTTPException(status_code=404, detail="练习不存在")
attempt = ExerciseAttempt(
exercise_id=exercise_id,
student_name=data.student_name,
answers=data.answers,
score=data.score,
total=data.total,
duration=data.duration,
)
db.add(attempt)
db.commit()
db.refresh(attempt)
return {"success": True, "id": attempt.id}
@router.get("/{exercise_id}/attempts", response_model=list[ExerciseAttemptOut])
def list_attempts(
exercise_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
exercise = db.query(Exercise).filter(
Exercise.id == exercise_id,
Exercise.user_id == current_user.id,
).first()
if not exercise:
raise HTTPException(status_code=404, detail="练习不存在")
return (
db.query(ExerciseAttempt)
.filter(ExerciseAttempt.exercise_id == exercise_id)
.order_by(ExerciseAttempt.created_at.desc())
.limit(200)
.all()
)
+245
View File
@@ -0,0 +1,245 @@
import re
from io import BytesIO
from urllib.parse import quote
from docx import Document
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from database import get_db
from models.lesson_plan import LessonPlan
from models.resource import Resource
from models.user import User
from schemas.lesson_plan import LessonPlanGenerate, LessonPlanCreate, LessonPlanOut, LessonPlanUpdate
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user, get_optional_current_user
from services.ai_service import AIService
router = APIRouter(prefix="/api/lesson-plans", tags=["教案管理"])
ai_service = AIService()
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教案"
return f"{stem[:80]}{suffix}"
def _as_list(value) -> list:
if isinstance(value, list):
return value
if value in (None, ""):
return []
return [value]
def _add_bullets(document: Document, items: list) -> None:
for item in items:
if isinstance(item, dict):
text = "".join(f"{key}{value}" for key, value in item.items() if value not in (None, "", []))
else:
text = str(item)
if text.strip():
document.add_paragraph(text.strip(), style="List Bullet")
def _lesson_plan_or_404(plan_id: int, current_user: User | None, db: Session) -> LessonPlan:
lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id).first()
if not lp:
raise HTTPException(status_code=404, detail="教案不存在")
if (not current_user or lp.user_id != current_user.id) and not has_public_resource(db, plan_id):
raise HTTPException(status_code=403, detail="无权访问该教案")
return lp
def has_public_resource(db: Session, plan_id: int) -> bool:
return db.query(Resource).filter(
Resource.content_ref == f"lesson_plan:{plan_id}",
Resource.status == "active",
Resource.is_public == 1,
).first() is not None
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_lesson_plan(
request: Request,
data: LessonPlanGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "lesson_plan_generate", f"生成教案:{data.title[:80]}")
db.commit()
result = await ai_service.generate_lesson_plan(
title=data.title, subject=data.subject, grade=data.grade,
objectives=data.objectives, duration=data.duration,
extra_requirements=data.extra_requirements,
)
return {"success": True, "data": result, "credits": credits_payload(account, "lesson_plan_generate")}
@router.post("/", response_model=LessonPlanOut, status_code=201)
def create_lesson_plan(data: LessonPlanCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
lp = LessonPlan(user_id=current_user.id, **data.model_dump())
db.add(lp)
db.commit()
db.refresh(lp)
return lp
@router.get("/", response_model=list[LessonPlanOut])
def list_lesson_plans(
subject: str = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(LessonPlan).filter(LessonPlan.user_id == current_user.id)
if subject:
query = query.filter(LessonPlan.subject == subject)
return query.order_by(LessonPlan.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{plan_id}", response_model=LessonPlanOut)
def get_lesson_plan(
plan_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _lesson_plan_or_404(plan_id, current_user, db)
@router.get("/{plan_id}/export-docx")
def export_lesson_plan_docx(
plan_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
lp = _lesson_plan_or_404(plan_id, current_user, db)
content = lp.content or {}
document = Document()
document.add_heading(lp.title or content.get("title") or "大单元教案", level=1)
meta = " / ".join(str(part) for part in [lp.subject, lp.grade, f"{lp.duration or 45}分钟"] if part)
if meta:
document.add_paragraph(meta)
sections = [
("教学目标", lp.objectives or content.get("objectives")),
("教学重点", lp.key_points or content.get("key_points")),
("教学难点", lp.difficulties or content.get("difficulties")),
("教学材料", lp.materials or content.get("materials")),
]
for title, items in sections:
items = _as_list(items)
if items:
document.add_heading(title, level=2)
_add_bullets(document, items)
phases = _as_list(content.get("phases") or content.get("steps"))
if phases:
document.add_heading("教学过程", level=2)
for index, phase in enumerate(phases, start=1):
if not isinstance(phase, dict):
document.add_paragraph(f"{index}. {phase}")
continue
name = phase.get("name") or phase.get("title") or f"教学环节 {index}"
duration = phase.get("duration")
heading = f"{index}. {name}"
if duration:
heading += f"{duration}分钟)"
document.add_heading(heading, level=3)
for label, key in [
("教学活动", "activities"),
("教师行为", "teacher_actions"),
("学生行为", "student_actions"),
("资源材料", "resources"),
("评价方式", "assessment"),
]:
items = _as_list(phase.get(key))
if items:
document.add_paragraph(label)
_add_bullets(document, items)
homework = _as_list(lp.homework or content.get("homework"))
if homework:
document.add_heading("课后作业", level=2)
_add_bullets(document, homework)
reflection = content.get("reflection")
if reflection:
document.add_heading("教学反思", level=2)
document.add_paragraph(str(reflection))
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(f"{lp.title or '大单元教案'}-教案", ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{plan_id}", response_model=LessonPlanOut)
def update_lesson_plan(
plan_id: int,
data: LessonPlanUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id, LessonPlan.user_id == current_user.id).first()
if not lp:
raise HTTPException(status_code=404, detail="教案不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(lp, key, value)
db.commit()
db.refresh(lp)
return lp
@router.post("/{plan_id}/remix", response_model=LessonPlanOut, status_code=201)
def remix_lesson_plan(
plan_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(LessonPlan).filter(LessonPlan.id == plan_id).first()
if not source:
raise HTTPException(status_code=404, detail="教案不存在")
if source.user_id != current_user.id and not has_public_resource(db, plan_id):
raise HTTPException(status_code=403, detail="无权改编该教案")
clone = LessonPlan(
user_id=current_user.id,
title=f"{source.title}(改编)",
subject=source.subject or "",
grade=source.grade or "",
objectives=list(source.objectives or []),
key_points=list(source.key_points or []),
difficulties=list(source.difficulties or []),
content=dict(source.content or {}),
duration=source.duration or 45,
materials=list(source.materials or []),
homework=list(source.homework or []),
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{plan_id}", status_code=204)
def delete_lesson_plan(plan_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id, LessonPlan.user_id == current_user.id).first()
if not lp:
raise HTTPException(status_code=404, detail="教案不存在")
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"lesson_plan:{plan_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.delete(lp)
db.commit()
+98
View File
@@ -0,0 +1,98 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
from database import get_db
from models.material import Material
from models.user import User
from schemas.material import MaterialCreate, MaterialOut, MaterialUpdate
from services.auth import get_current_user
router = APIRouter(prefix="/api/materials", tags=["素材库"])
@router.post("/", response_model=MaterialOut, status_code=201)
def create_material(
data: MaterialCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
material = Material(user_id=current_user.id, **data.model_dump())
db.add(material)
db.commit()
db.refresh(material)
return material
@router.get("/", response_model=list[MaterialOut])
def list_materials(
material_type: Optional[str] = None,
subject: Optional[str] = None,
keyword: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Material).filter(Material.user_id == current_user.id, Material.status == "active")
if material_type:
query = query.filter(Material.material_type == material_type)
if subject:
query = query.filter(Material.subject == subject)
if keyword:
query = query.filter(or_(
Material.title.contains(keyword),
Material.filename.contains(keyword),
Material.summary.contains(keyword),
Material.subject.contains(keyword),
Material.grade.contains(keyword),
))
return query.order_by(Material.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{material_id}", response_model=MaterialOut)
def get_material(
material_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
material = db.query(Material).filter(
Material.id == material_id,
Material.user_id == current_user.id,
Material.status == "active",
).first()
if not material:
raise HTTPException(status_code=404, detail="素材不存在")
return material
@router.put("/{material_id}", response_model=MaterialOut)
def update_material(
material_id: int,
data: MaterialUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
material = db.query(Material).filter(Material.id == material_id, Material.user_id == current_user.id).first()
if not material or material.status == "archived":
raise HTTPException(status_code=404, detail="素材不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(material, key, value)
db.commit()
db.refresh(material)
return material
@router.delete("/{material_id}", status_code=204)
def delete_material(
material_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
material = db.query(Material).filter(Material.id == material_id, Material.user_id == current_user.id).first()
if not material or material.status == "archived":
raise HTTPException(status_code=404, detail="素材不存在")
material.status = "archived"
db.commit()
+108
View File
@@ -0,0 +1,108 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from database import get_db
from models.mindmap import MindMap
from models.user import User
from schemas.mindmap import (
MindMapGenerate, MindMapCreate, MindMapUpdate, MindMapOut,
)
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user
from services.ai_service import AIService
router = APIRouter(prefix="/api/mind-maps", tags=["思维导图管理"])
ai_service = AIService()
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_mind_map(
request: Request,
data: MindMapGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "mindmap_generate", f"生成思维导图:{data.topic[:80]}")
db.commit()
result = await ai_service.generate_mindmap(
topic=data.topic, subject=data.subject, grade=data.grade,
)
return {"success": True, "data": result, "credits": credits_payload(account, "mindmap_generate")}
@router.post("/", response_model=MindMapOut, status_code=201)
def create_mind_map(
data: MindMapCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = MindMap(user_id=current_user.id, **data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.get("/", response_model=list[MindMapOut])
def list_mind_maps(
keyword: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(MindMap).filter(MindMap.user_id == current_user.id)
if keyword:
query = query.filter(MindMap.title.contains(keyword))
return query.order_by(MindMap.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{map_id}", response_model=MindMapOut)
def get_mind_map(
map_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(MindMap).filter(
MindMap.id == map_id, MindMap.user_id == current_user.id,
).first()
if not item:
raise HTTPException(status_code=404, detail="思维导图不存在")
return item
@router.put("/{map_id}", response_model=MindMapOut)
def update_mind_map(
map_id: int,
data: MindMapUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(MindMap).filter(
MindMap.id == map_id, MindMap.user_id == current_user.id,
).first()
if not item:
raise HTTPException(status_code=404, detail="思维导图不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return item
@router.delete("/{map_id}", status_code=204)
def delete_mind_map(
map_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(MindMap).filter(
MindMap.id == map_id, MindMap.user_id == current_user.id,
).first()
if not item:
raise HTTPException(status_code=404, detail="思维导图不存在")
db.delete(item)
db.commit()
return None
+101
View File
@@ -0,0 +1,101 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from database import get_db
from models.notification import Notification
from models.user import User
from schemas.notification import NotificationOut
from services.auth import get_current_user
router = APIRouter(prefix="/api/notifications", tags=["通知"])
def push_notification(
db: Session,
user_id: int,
actor_id: Optional[int],
ntype: str,
title: str,
content: str = "",
link: str = "",
) -> None:
"""创建一条通知,不通知自己。"""
if actor_id and actor_id == user_id:
return
db.add(Notification(
user_id=user_id, actor_id=actor_id, ntype=ntype,
title=title, content=content, link=link, is_read=False,
))
@router.get("", response_model=list[NotificationOut])
def list_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
unread_only: bool = Query(False),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Notification).filter(Notification.user_id == current_user.id)
if unread_only:
query = query.filter(Notification.is_read == False)
return query.order_by(Notification.created_at.desc()).offset(skip).limit(limit).all()
@router.get("/unread-count")
def unread_count(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
count = db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.is_read == False,
).count()
return {"count": count}
@router.put("/{notification_id}/read", response_model=dict)
def mark_read(
notification_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(Notification).filter(
Notification.id == notification_id,
Notification.user_id == current_user.id,
).first()
if not item:
raise HTTPException(status_code=404, detail="通知不存在")
item.is_read = True
db.commit()
return {"success": True}
@router.put("/read-all", response_model=dict)
def mark_all_read(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.is_read == False,
).update({"is_read": True}, synchronize_session=False)
db.commit()
return {"success": True}
@router.delete("/{notification_id}", status_code=204)
def delete_notification(
notification_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
item = db.query(Notification).filter(
Notification.id == notification_id,
Notification.user_id == current_user.id,
).first()
if not item:
raise HTTPException(status_code=404, detail="通知不存在")
db.delete(item)
db.commit()
return None
+267
View File
@@ -0,0 +1,267 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.resource import Resource, ResourceFavorite
from models.user import User
from schemas.resource import ResourceCreate, ResourceOut, ResourceUpdate
from services.auth import get_current_user, get_optional_current_user
from routers.notification import push_notification
from services.audit import log_action
router = APIRouter(prefix="/api/resources", tags=["资源库"])
def _sync_favorite_count(res: Resource, db: Session) -> None:
res.likes = db.query(ResourceFavorite).filter(ResourceFavorite.resource_id == res.id).count()
def _serialize_resource(res: Resource, current_user: User | None, db: Session) -> Resource:
is_favorited = False
if current_user:
is_favorited = db.query(ResourceFavorite).filter(
ResourceFavorite.user_id == current_user.id,
ResourceFavorite.resource_id == res.id,
).first() is not None
setattr(res, "is_favorited", is_favorited)
setattr(res, "author", res.author)
return res
def _apply_sort(query, sort: str | None):
sort = (sort or "latest").lower()
if sort == "popular":
return query.order_by(Resource.views.desc(), Resource.created_at.desc())
if sort in {"downloads", "download"}:
return query.order_by(Resource.downloads.desc(), Resource.created_at.desc())
if sort in {"likes", "like"}:
return query.order_by(Resource.likes.desc(), Resource.created_at.desc())
return query.order_by(Resource.created_at.desc())
@router.post("/", response_model=ResourceOut, status_code=201)
def create_resource(data: ResourceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
res = Resource(user_id=current_user.id, **data.model_dump())
db.add(res)
db.commit()
db.refresh(res)
return _serialize_resource(res, current_user, db)
@router.post("/publish", response_model=ResourceOut, status_code=201)
def publish_resource(request: Request, data: ResourceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
query = db.query(Resource).filter(Resource.user_id == current_user.id)
if data.content_ref:
query = query.filter(Resource.content_ref == data.content_ref)
else:
query = query.filter(Resource.title == data.title, Resource.resource_type == data.resource_type)
res = query.first()
is_new = res is None
if res:
for key, value in data.model_dump().items():
setattr(res, key, value)
res.status = "active"
else:
res = Resource(user_id=current_user.id, status="active", **data.model_dump())
db.add(res)
db.commit()
db.refresh(res)
log_action(db, action="publish_resource", user=current_user, request=request, target_type="resource", target_id=res.id, detail=f"{'新建' if is_new else '更新'}资源《{res.title}》({res.resource_type}")
return _serialize_resource(res, current_user, db)
@router.get("/", response_model=list[ResourceOut])
def list_resources(
resource_type: Optional[str] = None,
subject: Optional[str] = None,
keyword: Optional[str] = None,
sort: Optional[str] = Query("latest"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
query = db.query(Resource).filter(Resource.status == "active")
if resource_type:
query = query.filter(Resource.resource_type == resource_type)
if subject:
query = query.filter(Resource.subject == subject)
if keyword:
query = query.filter(or_(
Resource.title.contains(keyword),
Resource.description.contains(keyword),
Resource.subject.contains(keyword),
Resource.grade.contains(keyword),
))
if current_user:
query = query.filter((Resource.user_id == current_user.id) | (Resource.is_public == 1))
else:
query = query.filter(Resource.is_public == 1)
resources = _apply_sort(query, sort).offset(skip).limit(limit).all()
return [_serialize_resource(res, current_user, db) for res in resources]
@router.put("/{resource_id}", response_model=ResourceOut)
def update_resource(
resource_id: int,
data: ResourceUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
res = db.query(Resource).filter(Resource.id == resource_id, Resource.user_id == current_user.id).first()
if not res:
raise HTTPException(status_code=404, detail="资源不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(res, key, value)
db.commit()
db.refresh(res)
return _serialize_resource(res, current_user, db)
@router.get("/favorites/me", response_model=list[ResourceOut])
def list_favorite_resources(
resource_type: Optional[str] = None,
subject: Optional[str] = None,
keyword: Optional[str] = None,
sort: Optional[str] = Query("latest"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = (
db.query(Resource)
.join(ResourceFavorite, ResourceFavorite.resource_id == Resource.id)
.filter(ResourceFavorite.user_id == current_user.id, Resource.status == "active")
)
if resource_type:
query = query.filter(Resource.resource_type == resource_type)
if subject:
query = query.filter(Resource.subject == subject)
if keyword:
query = query.filter(or_(
Resource.title.contains(keyword),
Resource.description.contains(keyword),
Resource.subject.contains(keyword),
Resource.grade.contains(keyword),
))
order = ResourceFavorite.created_at.desc() if (sort or "latest") == "latest" else None
resources = (query.order_by(order) if order is not None else _apply_sort(query, sort)).offset(skip).limit(limit).all()
return [_serialize_resource(res, current_user, db) for res in resources]
@router.get("/mine", response_model=list[ResourceOut])
def list_my_resources(
resource_type: Optional[str] = None,
subject: Optional[str] = None,
keyword: Optional[str] = None,
sort: Optional[str] = Query("latest"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Resource).filter(Resource.user_id == current_user.id, Resource.status == "active")
if resource_type:
query = query.filter(Resource.resource_type == resource_type)
if subject:
query = query.filter(Resource.subject == subject)
if keyword:
query = query.filter(or_(
Resource.title.contains(keyword),
Resource.description.contains(keyword),
Resource.subject.contains(keyword),
Resource.grade.contains(keyword),
))
resources = (_apply_sort(query, sort) if (sort or "latest") != "latest" else query.order_by(Resource.updated_at.desc())).offset(skip).limit(limit).all()
return [_serialize_resource(res, current_user, db) for res in resources]
@router.get("/{resource_id}", response_model=ResourceOut)
def get_resource(
resource_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
res = db.query(Resource).filter(Resource.id == resource_id).first()
if not res or res.status == "archived":
raise HTTPException(status_code=404, detail="资源不存在")
if not res.is_public and (not current_user or res.user_id != current_user.id):
raise HTTPException(status_code=403, detail="无权访问该资源")
res.views = (res.views or 0) + 1
db.commit()
db.refresh(res)
return _serialize_resource(res, current_user, db)
@router.post("/{resource_id}/download", response_model=dict)
def download_resource(
resource_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
if not res:
raise HTTPException(status_code=404, detail="资源不存在")
if not res.is_public and (not current_user or res.user_id != current_user.id):
raise HTTPException(status_code=403, detail="无权访问该资源")
res.downloads = (res.downloads or 0) + 1
db.commit()
return {"success": True, "downloads": res.downloads, "file_url": res.file_url}
@router.post("/{resource_id}/like", response_model=dict)
def like_resource(
resource_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
if not res:
raise HTTPException(status_code=404, detail="资源不存在")
if not res.is_public and res.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问该资源")
favorite = db.query(ResourceFavorite).filter(
ResourceFavorite.user_id == current_user.id,
ResourceFavorite.resource_id == resource_id,
).first()
if not favorite:
db.add(ResourceFavorite(user_id=current_user.id, resource_id=resource_id))
db.flush()
push_notification(db, user_id=res.user_id, actor_id=current_user.id, ntype="like", title=f"{current_user.name} 收藏了你的资源", content=res.title[:60], link=f"/resources/{res.id}")
_sync_favorite_count(res, db)
db.commit()
return {"success": True, "likes": res.likes, "is_favorited": True}
@router.delete("/{resource_id}/like", response_model=dict)
def unlike_resource(
resource_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
if not res:
raise HTTPException(status_code=404, detail="资源不存在")
if not res.is_public and res.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问该资源")
favorite = db.query(ResourceFavorite).filter(
ResourceFavorite.user_id == current_user.id,
ResourceFavorite.resource_id == resource_id,
).first()
if favorite:
db.delete(favorite)
db.flush()
_sync_favorite_count(res, db)
db.commit()
return {"success": True, "likes": res.likes, "is_favorited": False}
@router.delete("/{resource_id}", status_code=204)
def delete_resource(resource_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
res = db.query(Resource).filter(Resource.id == resource_id, Resource.user_id == current_user.id).first()
if not res:
raise HTTPException(status_code=404, detail="资源不存在")
res.status = "archived"
db.commit()
+301
View File
@@ -0,0 +1,301 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
from database import get_db
from models.animation import Animation
from models.classroom import ClassroomActivity
from models.community import Post
from models.courseware import Courseware
from models.exam import Exam
from models.exercise import Exercise
from models.lesson_plan import LessonPlan
from models.material import Material
from models.mindmap import MindMap
from models.resource import Resource
from models.user import User
from schemas.search import SearchResponse
from services.auth import get_optional_current_user
router = APIRouter(prefix="/api/search", tags=["全局搜索"])
def _has_keyword(*values, keyword: str) -> bool:
if not keyword:
return True
haystack = " ".join(str(value or "") for value in values).lower()
return keyword.lower() in haystack
def _tag_list(value) -> list[str]:
if not isinstance(value, list):
return []
return [str(item) for item in value if str(item).strip()][:6]
def _item(
*,
id,
source: str,
source_label: str,
title: str,
route: str,
description: str = "",
subject: str = "",
grade: str = "",
tags=None,
action_route: str = "",
is_public: bool = False,
updated_at=None,
) -> dict:
return {
"id": id,
"source": source,
"source_label": source_label,
"title": title or "未命名",
"description": description or "",
"subject": subject or "",
"grade": grade or "",
"tags": _tag_list(tags),
"route": route,
"action_route": action_route,
"is_public": bool(is_public),
"updated_at": updated_at,
}
def _sort_key(item: dict):
return item["updated_at"] or ""
def _resource_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
query = db.query(Resource).filter(Resource.status == "active")
if current_user:
query = query.filter((Resource.user_id == current_user.id) | (Resource.is_public == 1))
else:
query = query.filter(Resource.is_public == 1)
if keyword:
query = query.filter(or_(
Resource.title.contains(keyword),
Resource.description.contains(keyword),
Resource.subject.contains(keyword),
Resource.grade.contains(keyword),
))
return [
_item(
id=res.id,
source="resource",
source_label="资源",
title=res.title,
description=res.description,
subject=res.subject,
grade=res.grade,
tags=res.tags,
route=f"/resources/{res.id}",
action_route=f"/resources/{res.id}",
is_public=bool(res.is_public),
updated_at=res.updated_at,
)
for res in query.order_by(Resource.updated_at.desc()).limit(limit).all()
]
def _community_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
query = db.query(Post)
if keyword:
query = query.filter(or_(Post.title.contains(keyword), Post.content.contains(keyword)))
return [
_item(
id=post.id,
source="community",
source_label="模板社区",
title=post.title,
description=post.content,
tags=post.tags,
route=f"/community/{post.id}",
action_route=f"/community/{post.id}",
is_public=True,
updated_at=post.updated_at,
)
for post in query.order_by(Post.updated_at.desc()).limit(limit).all()
]
def _my_work_queries(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
if not current_user:
return []
user_id = current_user.id
items = []
coursewares = db.query(Courseware).filter(Courseware.user_id == user_id, Courseware.status != "archived").all()
for work in coursewares:
if _has_keyword(work.title, work.description, work.subject, work.grade, " ".join(_tag_list(work.tags)), keyword=keyword):
items.append(_item(
id=work.id,
source="courseware",
source_label="我的课件",
title=work.title,
description=work.description,
subject=work.subject,
grade=work.grade,
tags=work.tags,
route=f"/courseware/{work.id}",
action_route=f"/courseware/{work.id}",
updated_at=work.updated_at,
))
animations = db.query(Animation).filter(Animation.user_id == user_id).all()
for work in animations:
if _has_keyword(work.title, work.description, work.anim_type, keyword=keyword):
items.append(_item(
id=work.id,
source="animation",
source_label="我的动画",
title=work.title,
description=work.description,
subject=str(work.anim_type or ""),
route=f"/animation?open={work.id}",
action_route=f"/animation?open={work.id}",
updated_at=work.updated_at,
))
exercises = db.query(Exercise).filter(Exercise.user_id == user_id).all()
for work in exercises:
if _has_keyword(work.title, work.subject, " ".join(_tag_list(work.knowledge_points)), keyword=keyword):
items.append(_item(
id=work.id,
source="exercise",
source_label="我的练习",
title=work.title,
subject=work.subject,
tags=work.knowledge_points,
route=f"/exercise?open={work.id}",
action_route=f"/exercise?open={work.id}",
updated_at=work.updated_at,
))
lesson_plans = db.query(LessonPlan).filter(LessonPlan.user_id == user_id).all()
for work in lesson_plans:
if _has_keyword(work.title, work.subject, work.grade, " ".join(_tag_list(work.objectives)), keyword=keyword):
items.append(_item(
id=work.id,
source="lesson_plan",
source_label="我的教案",
title=work.title,
subject=work.subject,
grade=work.grade,
tags=work.objectives,
route=f"/lesson-plan?open={work.id}",
action_route=f"/lesson-plan?open={work.id}",
updated_at=work.updated_at,
))
mind_maps = db.query(MindMap).filter(MindMap.user_id == user_id).all()
for work in mind_maps:
if _has_keyword(work.title, work.subject, keyword=keyword):
items.append(_item(
id=work.id,
source="mindmap",
source_label="我的导图",
title=work.title,
subject=work.subject,
route=f"/mindmap?open={work.id}",
action_route=f"/mindmap?open={work.id}",
updated_at=work.updated_at,
))
exams = db.query(Exam).filter(Exam.user_id == user_id).all()
for work in exams:
if _has_keyword(work.title, work.subject, work.grade, " ".join(_tag_list(work.knowledge_points)), keyword=keyword):
items.append(_item(
id=work.id,
source="exam",
source_label="我的试卷",
title=work.title,
subject=work.subject,
grade=work.grade,
tags=work.knowledge_points,
route=f"/exam?open={work.id}",
action_route=f"/exam?open={work.id}",
updated_at=work.updated_at,
))
items.sort(key=_sort_key, reverse=True)
return items[:limit]
def _material_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
if not current_user:
return []
query = db.query(Material).filter(Material.user_id == current_user.id, Material.status == "active")
if keyword:
query = query.filter(or_(
Material.title.contains(keyword),
Material.filename.contains(keyword),
Material.summary.contains(keyword),
Material.subject.contains(keyword),
Material.grade.contains(keyword),
))
return [
_item(
id=item.id,
source="material",
source_label="素材",
title=item.title,
description=item.summary,
subject=item.subject,
grade=item.grade,
tags=item.tags,
route="/materials",
action_route=f"/courseware/create",
updated_at=item.updated_at,
)
for item in query.order_by(Material.updated_at.desc()).limit(limit).all()
]
def _classroom_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
if not current_user:
return []
activities = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == current_user.id).all()
items = []
for activity in activities:
config = activity.config or {}
if _has_keyword(activity.title, activity.description, config.get("question"), activity.activity_type, keyword=keyword):
items.append(_item(
id=activity.id,
source="classroom",
source_label="课堂活动",
title=activity.title,
description=activity.description or config.get("question") or "",
subject=activity.activity_type or "",
route=f"/classroom?open={activity.id}",
action_route=f"/classroom?open={activity.id}",
updated_at=activity.updated_at,
))
items.sort(key=_sort_key, reverse=True)
return items[:limit]
@router.get("", response_model=SearchResponse)
def global_search(
keyword: str = Query("", max_length=100),
limit: int = Query(6, ge=1, le=20),
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
keyword = keyword.strip()
resources = _resource_query(keyword, current_user, db, limit)
community = _community_query(keyword, current_user, db, limit)
works = _my_work_queries(keyword, current_user, db, limit)
materials = _material_query(keyword, current_user, db, limit)
classroom = _classroom_query(keyword, current_user, db, limit)
return {
"keyword": keyword,
"total": len(resources) + len(community) + len(works) + len(materials) + len(classroom),
"resources": resources,
"community": community,
"works": works,
"materials": materials,
"classroom": classroom,
}
+9
View File
@@ -0,0 +1,9 @@
from .user import *
from .courseware import *
from .animation import *
from .exercise import *
from .lesson_plan import *
from .exam import *
from .resource import *
from .community import *
from .ai import *
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class AdminUserOut(BaseModel):
id: int
phone: str
name: str
avatar: str = ""
subject: str = ""
school: str = ""
grade: str = ""
role: str = "teacher"
is_active: bool = True
credits: int = 0
created_at: datetime | None = None
@field_validator("avatar", "subject", "school", "grade", "name", "role", mode="before")
@classmethod
def _none_to_empty(cls, v):
return "" if v is None else v
model_config = {"from_attributes": True}
class AdminUserUpdate(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
name: Optional[str] = None
class AdminGrantCredits(BaseModel):
amount: int = Field(..., gt=0, le=10000, description="积分数量")
class AdminDashboardOut(BaseModel):
total_users: int = 0
active_users: int = 0
admin_users: int = 0
total_resources: int = 0
public_resources: int = 0
total_coursewares: int = 0
total_animations: int = 0
total_exercises: int = 0
total_lesson_plans: int = 0
total_exams: int = 0
total_views: int = 0
total_downloads: int = 0
total_favorites: int = 0
total_credits_used: int = 0
new_users_today: int = 0
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field
from typing import Any
class AIRequest(BaseModel):
prompt: str = Field(..., min_length=1, description="AI请求内容")
context: dict[str, Any] = Field(default_factory=dict, description="上下文信息")
stream: bool = Field(default=False, description="是否流式返回")
class EssayGradeRequest(BaseModel):
essay_text: str = Field(..., min_length=10, description="作文文本")
grade_level: str = Field(default="初中", description="年级段")
essay_type: str = Field(default="记叙文", description="作文类型")
total_score: int = Field(default=50, description="满分")
class ExamGenerateRequest(BaseModel):
subject: str = Field(..., min_length=1)
grade: str = ""
knowledge_points: list[str] = []
difficulty: str = Field(default="medium", pattern=r"^(easy|medium|hard)$")
question_types: list[str] = Field(default=["choice"])
count: int = Field(default=10, ge=1, le=50)
total_score: int = Field(default=100)
class HtmlExportRequest(BaseModel):
title: str = Field(default="教学内容", max_length=200)
html: str = Field(..., min_length=1)
class ExamExportRequest(BaseModel):
title: str = Field(default="试卷", max_length=200)
subject: str = ""
grade: str = ""
questions: list[dict[str, Any]] = []
answers: list[Any] = []
+40
View File
@@ -0,0 +1,40 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Any
class AnimationGenerate(BaseModel):
prompt: str = Field(..., min_length=1, description="教学动画描述")
anim_type: str = Field(default="general", pattern=r"^(math|physics|chemistry|biology|geography|general)$")
subject: str = Field(default="", max_length=50)
class AnimationCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
anim_type: str = "general"
description: str = ""
config: dict[str, Any] = {}
class AnimationUpdate(BaseModel):
title: Optional[str] = None
anim_type: Optional[str] = None
description: Optional[str] = None
config: Optional[dict[str, Any]] = None
thumbnail: Optional[str] = None
status: Optional[str] = None
class AnimationOut(BaseModel):
id: int
user_id: int
title: str
anim_type: str = "general"
description: str = ""
config: dict = {}
thumbnail: str = ""
status: str = "draft"
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime
from pydantic import BaseModel, Field
class ConversationCreate(BaseModel):
title: str | None = Field(default=None, max_length=200)
subject: str = ""
grade: str = ""
class ConversationRename(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
pinned: bool | None = None
class ChatSend(BaseModel):
content: str = Field(..., min_length=1, max_length=4000)
subject: str = ""
grade: str = ""
class MessageOut(BaseModel):
id: int
role: str
content: str
created_at: datetime | None = None
model_config = {"from_attributes": True}
class ConversationOut(BaseModel):
id: int
title: str
subject: str = ""
grade: str = ""
pinned: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None
messages: list[MessageOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
+71
View File
@@ -0,0 +1,71 @@
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class ClassroomActivityCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
activity_type: str = Field(default="poll", max_length=50)
description: str = ""
config: dict[str, Any] = {}
status: str = "draft"
class ClassroomActivityUpdate(BaseModel):
title: Optional[str] = None
activity_type: Optional[str] = None
description: Optional[str] = None
config: Optional[dict[str, Any]] = None
responses: Optional[list[dict[str, Any]]] = None
status: Optional[str] = None
class ClassroomResponseCreate(BaseModel):
student_name: str = Field(default="", max_length=50)
answer: Any
meta: dict[str, Any] = {}
class ClassroomActivityOut(BaseModel):
id: int
user_id: int
title: str
activity_type: str = "poll"
description: str = ""
config: dict = {}
responses: list = []
status: str = "draft"
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class ClassroomOptionStat(BaseModel):
option: str
count: int = 0
percent: int = 0
class ClassroomKeywordStat(BaseModel):
keyword: str
count: int = 0
class ClassroomAnalysisOut(BaseModel):
activity_id: int
title: str
activity_type: str = "poll"
question: str = ""
response_count: int = 0
participant_count: int = 0
option_stats: list[ClassroomOptionStat] = Field(default_factory=list)
keywords: list[ClassroomKeywordStat] = Field(default_factory=list)
common_answers: list[str] = Field(default_factory=list)
mastery_level: str = "暂无数据"
accuracy: int | None = None
diagnosis: list[str] = Field(default_factory=list)
teaching_suggestions: list[str] = Field(default_factory=list)
followup_prompt: str = ""
generated_at: datetime | None = None
+67
View File
@@ -0,0 +1,67 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Any, Optional
class CommunityAuthorOut(BaseModel):
id: int
name: str = ""
avatar: str = ""
subject: str = ""
school: str = ""
model_config = {"from_attributes": True}
class PostCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
post_type: str = "discussion"
tags: list[str] = Field(default_factory=list)
attachments: list[dict[str, Any]] = Field(default_factory=list)
class PostUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=1)
post_type: Optional[str] = None
tags: Optional[list[str]] = None
attachments: Optional[list[dict[str, Any]]] = None
class PostOut(BaseModel):
id: int
user_id: int
title: str
content: str = ""
post_type: str = "discussion"
tags: list = Field(default_factory=list)
attachments: list = Field(default_factory=list)
views: int = 0
likes: int = 0
comments_count: int = 0
is_pinned: int = 0
is_favorited: bool = False
author: CommunityAuthorOut | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class CommentCreate(BaseModel):
content: str = Field(..., min_length=1)
parent_id: Optional[int] = None
class CommentOut(BaseModel):
id: int
post_id: int
user_id: int
content: str
parent_id: Optional[int] = None
likes: int = 0
author: CommunityAuthorOut | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
+69
View File
@@ -0,0 +1,69 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Any
class CoursewareCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
subject: str = Field(default="", max_length=50)
grade: str = Field(default="", max_length=50)
description: str = ""
content: list[Any] = []
tags: list[str] = []
status: str = "draft"
class CoursewareAIGenerate(BaseModel):
prompt: str = Field(..., min_length=1, description="教学想法/描述")
subject: str = Field(default="", max_length=50)
grade: str = Field(default="", max_length=50)
page_count: int = Field(default=8, ge=3, le=30)
aspect_ratio: str = Field(default="16:9", description="画面比例: 16:9, 4:3, 1:1")
class CoursewareUpdate(BaseModel):
title: Optional[str] = None
subject: Optional[str] = None
grade: Optional[str] = None
description: Optional[str] = None
content: Optional[list[Any]] = None
cover_image: Optional[str] = None
status: Optional[str] = None
tags: Optional[list[str]] = None
class CoursewareOut(BaseModel):
id: int
user_id: int
title: str
subject: str = ""
grade: str = ""
description: str = ""
content: list = []
cover_image: str = ""
status: str = "draft"
version: int = 1
tags: list = []
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class CoursewareShareOut(BaseModel):
id: int
courseware_id: int
token: str
title: str
subject: str = ""
grade: str = ""
description: str = ""
content: list = []
tags: list = []
views: int = 0
status: str = "active"
share_url: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class EssayGradeCreate(BaseModel):
title: str = Field(default="作文批改报告", min_length=1, max_length=200)
essay_text: str = Field(..., min_length=1)
grade_level: str = "初中"
essay_type: str = "记叙文"
total_score: int = 50
result: dict[str, Any] = Field(default_factory=dict)
class EssayGradeOut(BaseModel):
id: int
user_id: int
title: str
essay_text: str = ""
grade_level: str = "初中"
essay_type: str = "记叙文"
total_score: int = 50
result: dict = Field(default_factory=dict)
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class ExamCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
subject: str = ""
grade: str = ""
questions: list[dict[str, Any]] = []
answers: list[Any] = []
duration: int = 90
total_score: int = 100
difficulty: str = "medium"
knowledge_points: list[str] = []
class ExamUpdate(BaseModel):
title: Optional[str] = None
subject: Optional[str] = None
grade: Optional[str] = None
questions: Optional[list[dict[str, Any]]] = None
answers: Optional[list[Any]] = None
duration: Optional[int] = None
total_score: Optional[int] = None
difficulty: Optional[str] = None
knowledge_points: Optional[list[str]] = None
class ExamOut(BaseModel):
id: int
user_id: int
title: str
subject: str = ""
grade: str = ""
questions: list = []
answers: list = []
duration: int = 90
total_score: int = 100
difficulty: str = "medium"
knowledge_points: list = []
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+65
View File
@@ -0,0 +1,65 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Any
class ExerciseAIGenerate(BaseModel):
prompt: str = Field(..., min_length=1, description="练习生成描述")
exercise_type: str = Field(default="choice")
subject: str = ""
knowledge_points: list[str] = []
count: int = Field(default=5, ge=1, le=50)
class ExerciseCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
exercise_type: str = "choice"
subject: str = ""
knowledge_points: list[str] = []
questions: list[dict[str, Any]] = []
settings: dict[str, Any] = {}
class ExerciseUpdate(BaseModel):
title: Optional[str] = None
exercise_type: Optional[str] = None
subject: Optional[str] = None
knowledge_points: Optional[list[str]] = None
questions: Optional[list[dict[str, Any]]] = None
settings: Optional[dict[str, Any]] = None
class ExerciseAttemptCreate(BaseModel):
student_name: str = ""
answers: list[Any] = []
score: int = 0
total: int = 0
duration: int = 0
class ExerciseAttemptOut(BaseModel):
id: int
exercise_id: int
student_name: str = ""
answers: list[Any] = []
score: int = 0
total: int = 0
duration: int = 0
created_at: datetime | None = None
model_config = {"from_attributes": True}
class ExerciseOut(BaseModel):
id: int
user_id: int
title: str
exercise_type: str = "choice"
subject: str = ""
knowledge_points: list = []
questions: list = []
settings: dict = {}
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+57
View File
@@ -0,0 +1,57 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Any
class LessonPlanGenerate(BaseModel):
title: str = Field(..., min_length=1)
subject: str = ""
grade: str = ""
objectives: list[str] = []
duration: int = Field(default=45, ge=20, le=120)
extra_requirements: str = ""
class LessonPlanCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
subject: str = ""
grade: str = ""
objectives: list[str] = []
key_points: list[str] = []
difficulties: list[str] = []
content: dict[str, Any] = {}
duration: int = 45
materials: list[str] = []
homework: list[str] = []
class LessonPlanUpdate(BaseModel):
title: Optional[str] = None
subject: Optional[str] = None
grade: Optional[str] = None
objectives: Optional[list[str]] = None
key_points: Optional[list[str]] = None
difficulties: Optional[list[str]] = None
content: Optional[dict[str, Any]] = None
duration: Optional[int] = None
materials: Optional[list[str]] = None
homework: Optional[list[str]] = None
class LessonPlanOut(BaseModel):
id: int
user_id: int
title: str
subject: str = ""
grade: str = ""
objectives: list = []
key_points: list = []
difficulties: list = []
content: dict = {}
duration: int = 45
materials: list = []
homework: list = []
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class MaterialCreate(BaseModel):
filename: str = Field(..., min_length=1, max_length=255)
title: str = Field(..., min_length=1, max_length=200)
material_type: str = "text"
subject: str = ""
grade: str = ""
tags: list[str] = Field(default_factory=list)
summary: str = ""
char_count: int = 0
size: int = 0
source: str = "upload"
class MaterialUpdate(BaseModel):
title: Optional[str] = None
subject: Optional[str] = None
grade: Optional[str] = None
tags: Optional[list[str]] = None
summary: Optional[str] = None
status: Optional[str] = None
class MaterialOut(BaseModel):
id: int
user_id: int
filename: str
title: str
material_type: str = "text"
subject: str = ""
grade: str = ""
tags: list = Field(default_factory=list)
summary: str = ""
char_count: int = 0
size: int = 0
source: str = "upload"
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class MindMapGenerate(BaseModel):
topic: str = Field(..., min_length=1, max_length=200)
subject: str = "综合"
grade: str = ""
class MindMapCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
subject: str = "综合"
nodes: list[Any] = Field(default_factory=list)
class MindMapUpdate(BaseModel):
title: str | None = None
nodes: list[Any] | None = None
class MindMapOut(BaseModel):
id: int
user_id: int
title: str
subject: str = "综合"
nodes: list[Any] = Field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from pydantic import BaseModel
class NotificationOut(BaseModel):
id: int
user_id: int
actor_id: int | None = None
ntype: str = "comment"
title: str
content: str = ""
link: str = ""
is_read: bool = False
created_at: datetime | None = None
model_config = {"from_attributes": True}
+63
View File
@@ -0,0 +1,63 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Any
class ResourceAuthorOut(BaseModel):
id: int
name: str = ""
avatar: str = ""
subject: str = ""
school: str = ""
model_config = {"from_attributes": True}
class ResourceCreate(BaseModel):
resource_type: str = "other"
title: str = Field(..., min_length=1, max_length=200)
description: str = ""
content_ref: str = ""
file_url: str = ""
tags: list[str] = Field(default_factory=list)
subject: str = ""
grade: str = ""
is_public: int = 1
class ResourceUpdate(BaseModel):
resource_type: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
content_ref: Optional[str] = None
file_url: Optional[str] = None
cover_image: Optional[str] = None
tags: Optional[list[str]] = None
subject: Optional[str] = None
grade: Optional[str] = None
is_public: Optional[int] = None
status: Optional[str] = None
class ResourceOut(BaseModel):
id: int
user_id: int
resource_type: str
title: str
description: str = ""
content_ref: str = ""
file_url: str = ""
cover_image: str = ""
tags: list = Field(default_factory=list)
subject: str = ""
grade: str = ""
downloads: int = 0
likes: int = 0
views: int = 0
is_public: int = 1
is_favorited: bool = False
author: ResourceAuthorOut | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
+28
View File
@@ -0,0 +1,28 @@
from datetime import datetime
from pydantic import BaseModel, Field
class SearchResultItem(BaseModel):
id: int | str
source: str
source_label: str
title: str
description: str = ""
subject: str = ""
grade: str = ""
tags: list[str] = Field(default_factory=list)
route: str
action_route: str = ""
is_public: bool = False
updated_at: datetime | None = None
class SearchResponse(BaseModel):
keyword: str = ""
total: int = 0
resources: list[SearchResultItem] = Field(default_factory=list)
community: list[SearchResultItem] = Field(default_factory=list)
works: list[SearchResultItem] = Field(default_factory=list)
materials: list[SearchResultItem] = Field(default_factory=list)
classroom: list[SearchResultItem] = Field(default_factory=list)
+130
View File
@@ -0,0 +1,130 @@
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
from typing import Optional
class UserLogin(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
password: str = Field(..., min_length=6, max_length=50, description="密码")
class UserRegister(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$")
password: str = Field(..., min_length=6, max_length=50)
name: str = Field(..., min_length=2, max_length=50)
subject: str = Field(default="", max_length=50)
school: str = Field(default="", max_length=100)
grade: str = Field(default="", max_length=50)
class UserUpdate(BaseModel):
name: Optional[str] = None
avatar: Optional[str] = None
subject: Optional[str] = None
school: Optional[str] = None
grade: Optional[str] = None
class PasswordChange(BaseModel):
old_password: str = Field(..., min_length=6, max_length=50, description="当前密码")
new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
class UserOut(BaseModel):
id: int
phone: str
name: str
avatar: str = ""
subject: str = ""
school: str = ""
grade: str = ""
role: str = "teacher"
credits: int = 0
total_credits_granted: int = 0
total_credits_used: int = 0
created_at: datetime | None = None
@field_validator("avatar", "subject", "school", "grade", "name", "role", mode="before")
@classmethod
def _none_to_empty(cls, v):
return "" if v is None else v
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: int
exp: datetime
class ProfileRecentItem(BaseModel):
id: int
item_type: str
title: str
subtitle: str = ""
status: str = ""
updated_at: datetime | None = None
class CreditTransactionOut(BaseModel):
id: int
amount: int
action: str = ""
description: str = ""
balance_after: int = 0
created_at: datetime | None = None
model_config = {"from_attributes": True}
class CreditBenefitOut(BaseModel):
credits: int = 0
daily_amount: int = 0
daily_claimed: bool = False
next_claim_text: str = ""
recent_checkins: list[CreditTransactionOut] = Field(default_factory=list)
class ProfileStatsOut(BaseModel):
credits: int = 0
total_credits_granted: int = 0
total_credits_used: int = 0
total_works: int = 0
draft_works: int = 0
published_works: int = 0
published_resources: int = 0
public_resources: int = 0
private_resources: int = 0
favorite_resources: int = 0
total_views: int = 0
total_downloads: int = 0
total_favorites: int = 0
classroom_activities: int = 0
active_classroom_activities: int = 0
classroom_responses: int = 0
community_posts: int = 0
community_favorites: int = 0
community_comments: int = 0
works_by_type: dict[str, int] = Field(default_factory=dict)
resources_by_type: dict[str, int] = Field(default_factory=dict)
recent_works: list[ProfileRecentItem] = Field(default_factory=list)
recent_resources: list[ProfileRecentItem] = Field(default_factory=list)
recent_interactions: list[ProfileRecentItem] = Field(default_factory=list)
recent_credit_transactions: list[CreditTransactionOut] = Field(default_factory=list)
class SendCodeRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
class ResetPasswordRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$")
code: str = Field(..., min_length=6, max_length=6, description="6位验证码")
new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
+7
View File
@@ -0,0 +1,7 @@
from .auth import get_password_hash, verify_password, create_access_token, create_refresh_token, get_current_user
from .ai_service import AIService
__all__ = [
"get_password_hash", "verify_password", "create_access_token",
"create_refresh_token", "get_current_user", "AIService",
]
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
"""安全审计日志服务。
提供 log_action() 用于在任意路由中记录安全相关事件日志写入数据库 audit_logs
同时输出到 logger便于后续接入文件/集中式日志收集
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import Request
from sqlalchemy.orm import Session
from models.audit import AuditLog
from models.user import User
logger = logging.getLogger(__name__)
def _user_label(user: Optional[User]) -> str:
"""返回用户可读标识:优先 username,其次 phone,最后 id。"""
if user is None:
return ""
for attr in ("username", "phone", "name"):
val = getattr(user, attr, None)
if val:
return str(val)
return str(getattr(user, "id", ""))
def _client_ip(request: Optional[Request]) -> str:
if request is None:
return ""
try:
fwd = request.headers.get("x-forwarded-for")
if fwd:
return fwd.split(",")[0].strip()[:64]
client = getattr(request, "client", None)
if client and client.host:
return client.host[:64]
except Exception:
pass
return ""
def _user_agent(request: Optional[Request]) -> str:
if request is None:
return ""
try:
return (request.headers.get("user-agent") or "")[:255]
except Exception:
return ""
def log_action(
db: Session,
*,
action: str,
user: Optional[User] = None,
request: Optional[Request] = None,
target_type: str = "",
target_id: Any = "",
detail: str = "",
status: str = "success",
) -> None:
"""记录一条审计日志。失败不影响主流程。"""
try:
log = AuditLog(
user_id=user.id if user else None,
username=_user_label(user),
action=action[:64],
target_type=target_type[:32] if target_type else "",
target_id=str(target_id)[:64] if target_id else "",
detail=detail[:2000] if detail else "",
ip=_client_ip(request),
user_agent=_user_agent(request),
status=status[:16] if status else "success",
)
db.add(log)
db.commit()
except Exception as exc: # pragma: no cover - 日志失败不应中断业务
logger.warning("audit log failed: %s", exc)
try:
db.rollback()
except Exception:
pass
# 同时输出到日志通道
logger.info(
"audit action=%s user=%s target=%s/%s status=%s ip=%s",
action,
_user_label(user),
target_type,
target_id,
status,
_client_ip(request),
)
+87
View File
@@ -0,0 +1,87 @@
import logging
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from config import get_settings
from database import get_db
from models.user import User
logger = logging.getLogger(__name__)
security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)
settings = get_settings()
def get_password_hash(password: str) -> str:
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def create_access_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
logger.info(f"Created token for user {user_id}")
return token
def create_refresh_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = int(payload.get("sub"))
logger.info(f"Token decoded for user {user_id}")
except JWTError as e:
logger.error(f"JWT decode error: {e}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
except (TypeError, ValueError) as e:
logger.error(f"Invalid sub claim: {e}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
return user
def get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_security),
db: Session = Depends(get_db),
) -> User | None:
if credentials is None:
return None
try:
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = int(payload.get("sub"))
except (JWTError, TypeError, ValueError):
return None
user = db.query(User).filter(User.id == user_id).first()
if user is None or not user.is_active:
return None
return user
def get_admin_user(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
+103
View File
@@ -0,0 +1,103 @@
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models.credit import CreditAccount, CreditTransaction
from models.user import User
DEFAULT_CREDIT_BALANCE = 130
DAILY_CHECKIN_CREDITS = 10
CREDIT_COSTS = {
"courseware_generate": 8,
"animation_generate": 6,
"exercise_generate": 4,
"lesson_plan_generate": 5,
"exam_generate": 5,
"essay_grade": 2,
"chat_generate": 1,
"material_parse": 1,
}
def ensure_credit_account(db: Session, user: User) -> CreditAccount:
account = db.query(CreditAccount).filter(CreditAccount.user_id == user.id).first()
if account:
return account
account = CreditAccount(
user_id=user.id,
balance=DEFAULT_CREDIT_BALANCE,
total_granted=DEFAULT_CREDIT_BALANCE,
total_used=0,
)
db.add(account)
db.flush()
return account
def spend_credits(db: Session, user: User, action: str, description: str = "") -> CreditAccount:
cost = CREDIT_COSTS.get(action, 1)
account = ensure_credit_account(db, user)
if account.balance < cost:
raise HTTPException(status_code=402, detail=f"积分不足,本次需要 {cost} 分,当前剩余 {account.balance}")
account.balance -= cost
account.total_used += cost
db.add(CreditTransaction(
user_id=user.id,
amount=-cost,
action=action,
description=description,
balance_after=account.balance,
))
db.flush()
return account
def credits_payload(account: CreditAccount, action: str) -> dict:
return {
"balance": account.balance,
"cost": CREDIT_COSTS.get(action, 1),
"action": action,
}
def grant_credits(db: Session, user: User, amount: int, action: str = "grant", description: str = "") -> CreditAccount:
if amount <= 0:
raise HTTPException(status_code=400, detail="积分数量必须大于 0")
account = ensure_credit_account(db, user)
account.balance += amount
account.total_granted += amount
db.add(CreditTransaction(
user_id=user.id,
amount=amount,
action=action,
description=description,
balance_after=account.balance,
))
db.flush()
return account
def has_credit_transaction_today(db: Session, user: User, action: str) -> bool:
from datetime import datetime, time
today = datetime.now().date()
start = datetime.combine(today, time.min)
end = datetime.combine(today, time.max)
return db.query(CreditTransaction).filter(
CreditTransaction.user_id == user.id,
CreditTransaction.action == action,
CreditTransaction.created_at >= start,
CreditTransaction.created_at <= end,
).first() is not None
def grant_daily_checkin(db: Session, user: User) -> CreditAccount:
if has_credit_transaction_today(db, user, "daily_checkin"):
raise HTTPException(status_code=409, detail="今日积分已领取")
return grant_credits(
db,
user,
DAILY_CHECKIN_CREDITS,
action="daily_checkin",
description="每日登录领取积分",
)
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+27
View File
@@ -0,0 +1,27 @@
"""密码强度校验策略:拒绝常见弱密码,要求字母+数字组合。"""
import re
WEAK_PATTERNS = [
"123456", "123456789", "password", "111111", "000000", "888888",
"abc123", "qwerty", "654321", "12345678", "1234567", "admin",
]
CONSECUTIVE = ["0123456789", "9876543210", "abcdef", "qwerty", "asdfgh"]
def validate_password_strength(password: str) -> str | None:
"""返回错误提示;通过则返回 None。要求至少 6 位且含字母与数字。"""
if not password or len(password) < 6:
return "密码至少需要 6 个字符"
low = password.lower()
if low in WEAK_PATTERNS:
return "密码过于简单,请使用更复杂的密码"
if any(seq in low for seq in CONSECUTIVE):
return "密码包含连续字符,请更换"
has_letter = bool(re.search(r"[a-zA-Z]", password))
has_digit = bool(re.search(r"\d", password))
if not has_letter:
return "密码必须包含字母"
if not has_digit:
return "密码必须包含数字"
return None
+130
View File
@@ -0,0 +1,130 @@
"""文件上传安全校验。
提供 validate_upload() 对上传文件做
1. 文件名清洗去除路径危险字符控制长度
2. 扩展名白名单校验
3. MIME 与扩展名一致性校验
4. 文件魔数magic bytes校验防止伪装
5. 文件大小校验
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
from fastapi import HTTPException, UploadFile
# 允许的扩展名 → 期望的 MIME 前缀(; 前部分)
ALLOWED_TYPES: dict[str, str] = {
"txt": "text/",
"md": "text/",
"csv": "text/",
"json": "application/json",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"pdf": "application/pdf",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xls": "application/vnd.ms-excel",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"bmp": "image/bmp",
}
# 魔数签名(前若干字节)
MAGIC_SIGNATURES: dict[str, list[bytes]] = {
"pdf": [b"%PDF-"],
"png": [b"\x89PNG\r\n\x1a\n"],
"jpg": [b"\xff\xd8\xff"],
"jpeg": [b"\xff\xd8\xff"],
"gif": [b"GIF87a", b"GIF89a"],
"bmp": [b"BM"],
"webp": [b"RIFF"],
# Office 文件均为 ZIP 容器(PK\x03\x04
"docx": [b"PK\x03\x04"],
"pptx": [b"PK\x03\x04"],
"xlsx": [b"PK\x03\x04"],
"xls": [b"\xd0\xcf\x11\xe0"], # OLE 复合文档
}
MAX_FILENAME_LEN = 200
@dataclass
class UploadCheck:
filename: str
suffix: str
media_type: str
size: int
_FILENAME_BAD = re.compile("[\x00-\x1f<>:\"/\\|?*]")
def sanitize_filename(name: str) -> str:
"""清洗文件名:去路径、去控制/危险字符、限长。"""
# 仅保留文件名部分
name = re.split(r"[\\/]", name)[-1].strip()
name = _FILENAME_BAD.sub("_", name)
name = name.strip(". ")
if not name:
name = "upload"
if len(name) > MAX_FILENAME_LEN:
stem, dot, ext = name.rpartition(".")
if dot:
name = stem[: MAX_FILENAME_LEN - len(ext) - 1] + "." + ext
else:
name = name[:MAX_FILENAME_LEN]
return name
def _check_magic(suffix: str, head: bytes) -> bool:
sigs = MAGIC_SIGNATURES.get(suffix)
if not sigs:
return True # 无签名要求(文本类)默认放行
return any(head.startswith(sig) for sig in sigs)
def validate_upload(
file: UploadFile,
*,
content: bytes,
max_size: Optional[int] = None,
) -> UploadCheck:
"""校验上传文件,失败抛出 HTTPException。
参数 content 为已读取的字节内容用于魔数校验
"""
if max_size is not None and len(content) > max_size:
raise HTTPException(status_code=413, detail=f"文件不能超过 {max_size // (1024 * 1024)}MB")
if not content:
raise HTTPException(status_code=400, detail="上传文件为空")
raw_name = file.filename or "material"
filename = sanitize_filename(raw_name)
suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
media_type = (file.content_type or "").split(";")[0].strip().lower()
if suffix not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片",
)
# MIME 一致性:图片/office/pdf 必须匹配;文本类型放宽
expected_mime = ALLOWED_TYPES[suffix]
if expected_mime != "text/" and media_type and not media_type.startswith(expected_mime.split("/")[0]):
# 宽松:只校验主类型一致(image/* application/* 等)
if media_type.split("/")[0] != expected_mime.split("/")[0]:
raise HTTPException(status_code=415, detail="文件类型与扩展名不一致")
# 魔数校验
head = content[:16]
if not _check_magic(suffix, head):
raise HTTPException(status_code=415, detail="文件内容与声明类型不符")
return UploadCheck(filename=filename, suffix=suffix, media_type=media_type or expected_mime, size=len(content))