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

- 前端: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
+53
View File
@@ -0,0 +1,53 @@
# Dependencies
node_modules/
frontend/node_modules/
# Build output
dist/
build/
frontend/dist/
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
.venv/
venv/
# Secrets & env (contains API keys)
.env
*.env.local
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
backend_stdout.log
backend_stderr.log
# Temp / debug / scratch files
_*.png
_*.txt
_*.json
_shots/
.claude/
debug_json_fail.txt
raw_ai.txt
_gen_dump.json
_cw_id.txt
test_frontend_api.py
test_output.json
*.bak
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
+118
View File
@@ -0,0 +1,118 @@
# 智教助手
面向教师 AI 教学创作场景设计的智能体系统,帮助教师将教学想法快速转化为互动课件、教学动画、课堂工具和教案成果。
## 核心功能
| 模块 | 功能 | 说明 |
|------|------|------|
| AI课件生成 | 一句话生成互动课件 | 输入教学想法,AI自动生成交互式课件 |
| 教学动画 | 交互式教学动画生成 | 数学公式可视化、物理实验模拟等 |
| 互动练习 | 游戏化练习生成 | 贪吃蛇、消消乐等趣味单词/知识点练习 |
| 教案生成 | 智能教案辅助 | AI根据课程内容生成结构化教案 |
| AI命题 | 智能出题 | 根据知识点自动生成各类题型 |
| 作文批改 | AI作文评分与批注 | 多维度作文分析与改进建议 |
| 课堂工具 | 实时课堂互动 | 白板、倒计时、答题器、连线等 |
| 资源库 | 教学资源共享 | 课件、动画、教案等资源管理 |
| 教师社区 | 同行交流 | 资源分享、经验交流社区 |
## 技术栈
### 后端
- **框架**: Python 3.11 + FastAPI
- **数据库**: SQLite (开发) / PostgreSQL (生产)
- **ORM**: SQLAlchemy 2.0 + Alembic
- **缓存**: Redis
- **AI**: OpenAI API 兼容接口
- **任务队列**: Celery + Redis
### 前端
- **框架**: Vue 3 + TypeScript + Vite
- **UI库**: Element Plus
- **状态管理**: Pinia
- **图表**: ECharts
- **动画**: Lottie + Canvas
- **编辑器**: TinyMCE / WangEditor
### 部署
- **容器化**: Docker + Docker Compose
- **Web服务器**: Nginx
- **CI/CD**: GitHub Actions
## 项目结构
```
jiaoyu/
├── README.md
├── docs/ # 项目文档
│ ├── architecture.md # 架构设计
│ └── api.md # API文档
├── backend/ # 后端服务
│ ├── requirements.txt
│ ├── main.py # 应用入口
│ ├── config.py # 配置管理
│ ├── database.py # 数据库连接
│ ├── models/ # SQLAlchemy模型
│ ├── schemas/ # Pydantic模型
│ ├── routers/ # API路由
│ ├── services/ # 业务逻辑
│ └── utils/ # 工具函数
├── frontend/ # 前端应用
│ ├── package.json
│ ├── vite.config.ts
│ ├── index.html
│ └── src/
│ ├── main.ts
│ ├── App.vue
│ ├── router/
│ ├── stores/
│ ├── api/
│ ├── views/
│ ├── components/
│ ├── styles/
│ └── utils/
└── docker-compose.yml
```
## 快速开始
### 后端启动
```bash
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # 编辑配置
python main.py
```
后端运行在 http://localhost:8000API文档 http://localhost:8000/docs
### 前端启动
```bash
cd frontend
npm install
npm run dev
```
前端运行在 http://localhost:5173
### Docker 部署
```bash
docker-compose up -d
```
## 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| DATABASE_URL | 数据库连接字符串 | sqlite:///./jiaoyu.db |
| REDIS_URL | Redis连接 | redis://localhost:6379 |
| AI_API_KEY | AI服务API密钥 | - |
| AI_API_BASE | AI服务基础URL | https://api.openai.com/v1 |
| AI_MODEL | 默认AI模型 | gpt-4o |
| SECRET_KEY | JWT密钥 | - |
| CORS_ORIGINS | 允许的跨域来源 | http://localhost:5173 |
+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))
+57
View File
@@ -0,0 +1,57 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://jiaoyu:jiaoyu123@db:5432/jiaoyu
- REDIS_URL=redis://redis:6379/0
- AI_API_KEY=${AI_API_KEY}
- AI_API_BASE=${AI_API_BASE:-https://api.openai.com/v1}
- AI_MODEL=${AI_MODEL:-gpt-4o}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- CORS_ORIGINS=["http://localhost","http://localhost:5173"]
volumes:
- ./uploads:/app/uploads
depends_on:
- db
- redis
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: jiaoyu
POSTGRES_USER: jiaoyu
POSTGRES_PASSWORD: jiaoyu123
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
+109
View File
@@ -0,0 +1,109 @@
# API 接口文档
Base URL: `http://localhost:8000`
## 认证模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/auth/register | 教师注册 |
| POST | /api/auth/login | 教师登录 |
| GET | /api/auth/me | 获取个人信息 |
| PUT | /api/auth/me | 更新个人信息 |
## 课件模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/coursewares/ | 创建课件 |
| GET | /api/coursewares/ | 课件列表(支持筛选) |
| GET | /api/coursewares/{id} | 课件详情 |
| PUT | /api/coursewares/{id} | 更新课件 |
| DELETE | /api/coursewares/{id} | 删除课件 |
| POST | /api/coursewares/ai-generate | AI生成课件 |
## 教学动画模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/animations/ | 创建动画 |
| GET | /api/animations/ | 动画列表 |
| GET | /api/animations/{id} | 动画详情 |
| PUT | /api/animations/{id} | 更新动画 |
| DELETE | /api/animations/{id} | 删除动画 |
| POST | /api/animations/ai-generate | AI生成动画 |
## 互动练习模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/exercises/ | 创建练习 |
| GET | /api/exercises/ | 练习列表 |
| GET | /api/exercises/{id} | 练习详情 |
| DELETE | /api/exercises/{id} | 删除练习 |
| POST | /api/exercises/ai-generate | AI生成练习 |
| POST | /api/exercises/{id}/attempt | 提交答题 |
## 教案模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/lesson-plans/ | 创建教案 |
| GET | /api/lesson-plans/ | 教案列表 |
| GET | /api/lesson-plans/{id} | 教案详情 |
| DELETE | /api/lesson-plans/{id} | 删除教案 |
| POST | /api/lesson-plans/ai-generate | AI生成教案 |
## AI能力模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/ai/essay-grade | 作文批改 |
| POST | /api/ai/exam-generate | AI命题 |
## 资源库模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/resources/ | 上传资源 |
| GET | /api/resources/ | 资源列表 |
| GET | /api/resources/{id} | 资源详情 |
| POST | /api/resources/{id}/like | 点赞 |
| DELETE | /api/resources/{id} | 删除资源 |
## 教师社区模块
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/community/posts | 发布帖子 |
| GET | /api/community/posts | 帖子列表 |
| GET | /api/community/posts/{id} | 帖子详情 |
| POST | /api/community/posts/{id}/like | 点赞帖子 |
| DELETE | /api/community/posts/{id} | 删除帖子 |
| GET | /api/community/posts/{id}/comments | 评论列表 |
| POST | /api/community/posts/{id}/comments | 发表评论 |
## 认证方式
所有需要认证的接口请在请求头中携带 JWT Token:
```
Authorization: Bearer <access_token>
```
## 通用响应格式
成功响应:
```json
{
"success": true,
"data": { ... }
}
```
错误响应:
```json
{
"detail": "错误信息描述"
}
```
+202
View File
@@ -0,0 +1,202 @@
# 系统架构设计
## 整体架构
```
┌─────────────────────────────────────────────────────────┐
│ Nginx 反向代理 │
├────────────────────────┬────────────────────────────────┤
│ 前端 (Vue 3 SPA) │ 静态资源 / CDN │
├────────────────────────┴────────────────────────────────┤
│ API Gateway (FastAPI) │
├──────────┬──────────┬──────────┬──────────┬─────────────┤
│ 认证模块 │ 课件模块 │ AI模块 │ 课堂模块 │ 社区模块 │
├──────────┴──────────┴──────────┴──────────┴─────────────┤
│ 服务层 (Business Logic) │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐ │
│ │AI服务 │ │课件引擎 │ │动画引擎 │ │ 练习生成引擎 │ │
│ └─────────┘ └──────────┘ └─────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 数据层 (Data Layer) │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │PostgreSQL │ │ Redis │ │ 文件存储 (OSS) │ │
│ └──────────┘ └──────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## 模块划分
### 1. 用户与认证模块 (auth)
- 教师注册/登录 (手机号 + 验证码)
- JWT Token 认证
- 角色权限管理 (教师/管理员)
- 个人信息管理
### 2. AI课件生成模块 (courseware)
**核心流程:**
```
教师输入 → AI理解意图 → 结构化课件大纲 → 逐页生成内容 → 互动元素注入 → 预览/编辑 → 发布
```
**子功能:**
- 文本输入 → 课件生成
- 语音输入 → 转写 → 课件生成
- 上传教材/文档 → 内容提取 → 课件生成
- 课件模板管理
- 课件在线编辑 (富文本 + 互动组件)
### 3. 教学动画模块 (animation)
**动画类型:**
- 数学: 函数图像动画、几何变换、公式推导
- 物理: 力学模拟、电路实验、光学实验
- 化学: 分子结构、化学反应过程
- 通用: 流程图动画、时间轴动画
**技术实现:**
- 基于 Canvas/SVG 的动画引擎
- AI生成动画参数配置 (JSON Schema)
- 动画模板库 + 自定义动画
### 4. 互动练习模块 (exercise)
**练习类型:**
- 游戏化练习: 贪吃蛇(单词)、消消乐(配对)、闯关
- 选择题 / 判断题 / 填空题
- 拖拽排序 / 连线匹配
- 口语跟读 (语音识别)
- AI自适应出题
**生成流程:**
```
知识点/单词表 → AI分析 → 选择游戏模板 → 生成题目 → 预览 → 发布
```
### 5. 教案生成模块 (lesson-plan)
- 根据课程标准和知识点生成教案
- 教案模板: 导入→新授→练习→总结→作业
- 支持自定义教案结构
- 教案与课件/练习关联
### 6. AI命题模块 (exam)
- 按知识点、难度、题型智能组卷
- 题型支持: 选择、填空、判断、简答、计算
- 题目去重与相似度检测
- 自动生成答案与解析
### 7. 作文批改模块 (essay)
- 多维度评分: 内容、结构、语言、书写
- 逐段/逐句批注
- 优秀范文推荐
- 常见错误统计分析
### 8. 课堂互动模块 (classroom)
- 在线授课 (WebRTC)
- 实时白板 (Canvas)
- 计时器 / 随机点名
- 课堂答题器 (实时统计)
- 课堂评价
### 9. 资源库模块 (resource)
- 个人资源管理
- 课件/动画/教案收藏
- 标签分类与搜索
- 资源版本管理
### 10. 教师社区模块 (community)
- 资源发布与分享
- 经验交流帖
- 点赞/评论/收藏
- 关注体系
## 数据库设计 (核心表)
```sql
-- 用户表
users: id, phone, name, avatar, subject, school, role, created_at
-- 课件表
coursewares: id, user_id, title, subject, grade, description,
content(JSON), status, version, created_at, updated_at
-- 动画表
animations: id, user_id, title, type, config(JSON),
thumbnail, status, created_at
-- 练习表
exercises: id, user_id, title, type, questions(JSON),
subject, knowledge_points, created_at
-- 教案表
lesson_plans: id, user_id, title, subject, grade, content(JSON),
objectives, key_points, created_at
-- 试卷表
exams: id, user_id, title, subject, questions(JSON),
answers(JSON), duration, total_score, created_at
-- 资源表
resources: id, user_id, type, title, content_ref,
tags, downloads, likes, status, created_at
-- 社区帖子表
posts: id, user_id, title, content, type, tags,
views, likes, comments_count, created_at
-- 评论表
comments: id, post_id, user_id, content, created_at
```
## AI服务架构
```
┌──────────────────────────────────────┐
│ AI Service Layer │
├──────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────────┐ │
│ │ Prompt模板 │ │ 教育知识库 │ │
│ │ 管理 │ │ (RAG) │ │
│ └────────────┘ └────────────────┘ │
│ │
│ ┌────────────────────────────────┐ │
│ │ LLM API 调用层 │ │
│ │ (OpenAI / 自研模型 / 本地模型) │ │
│ └────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────┐ │
│ │ 输出处理与验证 │ │
│ │ (JSON Schema验证/安全过滤) │ │
│ └────────────────────────────────┘ │
│ │
└──────────────────────────────────────┘
```
### Prompt 工程
每种AI功能对应一套精心设计的Prompt模板:
1. **课件生成Prompt**: 输入教学意图 → 输出结构化课件JSON
2. **动画生成Prompt**: 输入知识点 → 输出动画参数配置
3. **练习生成Prompt**: 输入知识点/单词 → 输出游戏化练习数据
4. **教案生成Prompt**: 输入课程信息 → 输出结构化教案
5. **命题Prompt**: 输入知识点/难度 → 输出题目+答案+解析
6. **批改Prompt**: 输入作文 → 输出评分+批注+建议
## 安全设计
- JWT认证 + Refresh Token
- API限流 (Redis)
- 输入校验 (Pydantic)
- XSS/CSRF防护
- 文件上传安全检查
- 数据库参数化查询
- 敏感信息加密存储
+83
View File
@@ -0,0 +1,83 @@
# 同类 AI 教学平台功能路线
本项目目标是实现同类 AI 教学创作平台能力,功能逻辑对标,但品牌、文案、视觉素材和生成模板保持原创。
## 已落地
| 能力 | 当前实现 |
| --- | --- |
| 创作广场 | 首页资源卡片、分类筛选、快速创作入口 |
| 一句话生成互动课件 | `/courseware/create`,AI 生成多页 HTML 互动课件 |
| 教学动画 | `/animation`AI 生成 HTML/CSS 教学动画,支持数学/物理/化学/生物/地理/通用 |
| 教学游戏/互动练习 | `/exercise`,8种题型,互动答题(选择/填空)、即时反馈、计分、答案解析 |
| AI 教案 | `/lesson-plan`,按课题、学科、年级、目标生成教案 |
| AI 命题 | `/exam`,按学科、知识点、题型、难度生成试卷 |
| 作文批改 | `/essay-grade`,作文评分、评语、优缺点和修改建议 |
| 模板社区/资源库 | `/community``/resources`,资源展示、发布、点赞、评论;站内通知系统(评论/收藏自动触发、头部铃铛未读计数、一键已读) |
| 课堂工具/数据回收 | `/classroom`,投票、问答、测验、签到、反馈,支持提交统计 |
| 多模态入口雏形 | 首页支持文本材料上传、图片描述注入、浏览器语音识别 |
| 成果导出 | 课件/动画类 HTML 下载,试卷/教案 Word 下载 + 打印 PDF(浏览器原生打印对话框,可另存为 PDF) |
| 材料智能解析 | 后端解析 docx、pptx、pdf、xlsx/txt/md/csv/json,图片经 AI 视觉 OCR 提取文字 |
| 数据回收学生端 | `/classroom/join` 公开提交页、投放码/链接、投放大屏、实时统计、Excel 导出 |
| 会员/积分体系 | 生成扣费、每日签到、积分流水、余额实时同步 |
| 课件编辑器页面管理 | 添加/复制/移动/删除页面,页面标题、类型与备注编辑 |
| 课件编辑器撤销重做 | 历史栈撤销/重做,Ctrl+Z / Ctrl+Shift+Z 快捷键 |
| 管理后台 | 运营看板、用户管理(启停/角色/发积分)、资源审核(上下架) |
| 数据库迁移 | Alembic 初始化,autogenerate + batch 模式,初始迁移已 stamp |
| 接口限流 | slowapi 限流:登录/注册、AI 生成、材料解析接口 |
| 账号安全 | 修改密码(校验旧密码/禁止新旧相同/密码强度策略/审计日志)+ 找回密码(手机号验证码重置、限流、验证码 5 分钟过期)+ 头像上传(图片白名单/魔数校验/5MB) |
| 令牌刷新 | access_token 过期自动用 refresh_token 静默刷新(单飞去重,避免并发 401 重复刷新),刷新失败才跳登录;登录/注册同时存储 refresh_token |
| AI 思维导图 | /mindmap,AI 生成层次化知识思维导图,交互式可折叠树、分支配色、展开/折叠、HTML 导出、保存到我的导图(CRUD + 耗积分);后端 mind_maps 表与路由、AIService.generate_mindmap + fallback |
| 动画模板库 | 36 个参数化学科动画模板:二次函数、正弦波、斜抛运动、简谐振动、分子运动、细胞分裂、太阳系公转、昼夜四季、几何变换、串并联电路、食物链能量流动、水循环、历史时间线、朝代更替轮、诗词意境、分数可视化、光的折射与全反射、酸碱中和滴定、光合作用过程、概率模拟(大数定律)、单词卡片翻转记忆(英语词汇)、英语时态时间轴(英语语法)、汉字笔顺动画(语文识字)、钢琴键盘与简谱(音乐)、三原色混色实验(美术)、二进制拨码开关(信息技术)、拼音四声调对比(语文)、元素周期表交互、化学键与分子结构、pH酸碱指示剂(化学拓展);覆盖数学/物理/化学(5)/生物/地理/历史/语文/英语/音乐/美术/信息技术/通用 12 学科;TemplateGallery 组件接入动画生成页,免积分即用即改 |
| 游戏模板库 | 20 个参数化教学游戏模板:知识贪吃蛇、翻牌记忆配对、排序拖拽、冒险闯关问答、抢答打地鼠、填词连连看、转盘答题、找词游戏、知识飞行棋、知识合成 2048、打字竞速、猜词游戏(猜字母拼单词)、判断对错速答(限时连对奖励)、选词填空(Cloze 拖放)、算术竞速(四则限时);TemplateGallery 接入练习生成页,模板自动映射题型;并修复贪吃蛇/转盘/找词三个旧模板的脚本语法错误 |
| 课件编辑器增强 | 6 套主题模板(简洁/暖橙/海洋/森林/深夜/日落)一键换肤 + 模板插入按钮(TemplateGallery 复用);保留页面增删改/撤销重做 |
| 品牌替换 | 汉字/拼音/CSS 前缀全量切换为自有品牌 zj-(前端样式与后端生成模板),源码与 AI 生成内容(课件/动画/教案等)经全工作区扫描 + 多类型生成验证零残留 |
| 模板插值修复 | 修复 strokeOrder/4 个新模板的反引号字符串插值 bug:原 `var X=\"+name+\"` 写法在浏览器里被解析为字面字符串 `\"+name+\"` 而非变量值,导致参数实际未生效(仅靠 `\|\| 默认值` 兜底掩盖);统一改为正确的 `${name}` 模板插值,参数现在真正传入 IIFE |
| AI 实时生成可用 | 修复核心瓶颈:`_chat` 读取超时 8s→120s(推理模型需 20-90s 思考)、OCR 30s→90s;空内容守卫(reasoning 模型思考耗尽预算时抛 `AI_PROVIDER_EMPTY_RESPONSE` 触发兜底);`max_tokens` 默认 4096→8192(防复杂生成 JSON 截断),chat_teaching 2048→8192、mindmap 4096→8192;端到端验证:练习(勾股定理几何意义,92s,5题)+试卷(一元二次方程,64s,6题)均产出高质量真实内容,无截断、无兜底模板 |
| JWT 密钥安全 | 修复高危漏洞:SECRET_KEY 原硬编码为 `change-me-in-production` 默认值,源码泄露即可伪造任意用户/管理员 token。改为 pydantic field_validator 启动校验——生产模式(DEBUG=False)遇到弱/默认/空/短(<32)密钥直接 RuntimeError 拒绝启动;开发模式自动生成临时密钥并警告;.env 已配置 64 字符随机强密钥。端到端验证:旧密钥伪造 token 返回 401 |
| 生产安全 | 上传安全校验(扩展名白名单/MIME 一致性/魔数检测/文件名清洗/大小限制)+ 审计日志覆盖登录/注册/材料解析/发布资源/管理员发积分/改用户/资源审核,admin 可查询 + AuditLog 表与 Alembic 迁移 |
| 练习/命题兜底题库扩展 | _exercise_kb() 从 14 个主题扩展到 35 个,覆盖历史(朝代/丝路/工业革命/抗战/文艺复兴/辛亥革命)、地理(气候/地形/河流/地球运动)、生物(细胞/光合/生态/遗传/人体)、化学(元素/反应/酸碱盐/原子)、物理(力学/电学/光学/运动)、数学(几何三角形/函数/方程/一元二次/二次函数/概率统计)、语文(荷塘月色/古诗词/记叙文/议论文)、英语(时态/从句);修复核心缺口:用户请求「三角形内角和」时不再退回无关算术,而是生成三角形内角和(180°)、直角三角形判定、勾股定理等主题相关题目 |
| AI 熔断器全局共享 | _ai_unavailable_until 从实例变量改为类变量,8 个路由模块的 AIService 实例共享一个熔断器;首次 AI 超时后,所有端点的后续调用立即走兜底(0.2s),不再各自独立等待 10s 超时(5 端点连续调用从 ~50s 降至 ~11s) |
## 端到端验证覆盖(双账号真实链路)
以下链路均经真实 HTTP 调用端到端验证通过:
| 链路 | 验证结果 |
| --- | --- |
| 认证积分 | 注册201/登录/me初始130积分/签到+10/140 通过 |
| AI生成8端点 | 课件/动画/教案/练习/思维导图/命题/作文批改全200,内容质量已核验 |
| 社交链路 | A发资源→B收藏通知→B下载→B发帖→A评论→A点赞双向通知→全局搜索7维度 通过 |
| 收藏状态 | 跨账号is_favorited正确/收藏列表同步/取消重收准确 通过 |
| 课堂数据回收 | 创建投票→4学生公开提交→分析统计(选项50%/掌握度/教学建议)→Excel导出 通过 |
| 课件分享 | 生成token→公开访问→浏览数递增→我的分享列表→撤销后404 通过 |
| 旧品牌清除 | 源码0残留+所有生成类型产出0残留(课件原117处旧前缀全部清零) 通过 |
| 平台完整性审计 | 24视图无空壳(最小366行)、17路由+~125个前端API端点与后端对齐、3处TODO标记均为UI文案非未实现、找回密码/分享/通知/搜索等关键链路前后端完整;Playwright真实浏览器端到端验证:登录→首页(H1正确)→课件/动画/练习页加载→动画生成API 200→0控制台错误→无旧品牌残留 |
| AI 兜底质量增强 | 修复 `backend/services/ai_service.py``_exercise_kb` 重复定义导致的 SyntaxError`""书名""` 双引号嵌套解析失败,删除冗余副本,保留已用《》/「」的正确版本);思维导图/练习/教案 fallback 升级为学科+主题感知题库(30+ 高频主题:朝代/丝绸之路/光合作用/细胞/元素周期表/酸碱盐/欧姆定律/重力/一元二次方程/二次函数/荷塘月色/古诗词/时态/气候),冷门主题回退通用题库无回归;时态关键词扩充(现在时/过去时/进行时/完成时)、朝代关键词扩充(唐/汉/宋/明/清/秦);HTTP + Playwright 端到端验证:一元二次方程→x²-5x+6=0、光合作用→叶绿体、欧姆定律→I=U/R、荷塘月色→朱自清、唐朝→朝代题库、0 控制台错误 |
| 练习标题去重 | 修复 `_fallback_exercise` 兜底标题把整段 prompt 当主题再拼「练习」导致「…练习练习」重复:改为正则去尾(练习题/练习/题目/试题/习题/题库/选择题/填空题/判断题/应用题/解答题/计算题/题 等)后再拼;已端到端验证 prompt「二次函数练习」→ 标题「二次函数练习」(原「二次函数练习练习」),3 题正常返回;全工作区旧品牌残留复核:源码+数据库+node_modules 字节级扫描 0 命中;16 个主视图浏览器遍历 0 控制台错误,登录/课件生成链路运行时健康 |
| 练习作答追踪(attempt | 后端修复 POST `/{id}/attempt`(原忽略路径参数 exercise_id,改为显式赋值并校验练习存在);新增 GET `/{id}/attempts` 供教师查询作答记录;schema 新增 ExerciseAttemptOut、ExerciseAttemptCreate 去掉冗余 exercise_id;前端新增 submitExerciseAttempt/getExerciseAttempts API + Exercise.vue 自动提交(答完全部题目或重置时触发,仅对已保存练习生效)+ 预览时加载并展示「已有 N 人完成 · 平均正确率 M%」徽章;端到端验证:创建练习→提交2条作答(2/3, 3/3)→GET列表返回2条完整记录 |
| 全链路深度审计 | 品牌复核(源码+DB+node_modules 字节级 0 残留);87前端API路径全部有对应后端端点;16主视图浏览器遍历 0 控制台错误;资源CRUD/课堂回收/社交/AI对话/签到/Profile统计 各链路端到端 HTTP 验证通过;TypeScript 零编译错误 |
| 管理后台致命 Bug 修复 | admin.py 缺少 `from services.audit import log_action` 导入,导致 update_user / grant_user_credits / moderate_resource 三个端点全部 500NameError)。修复后端到端验证:发放积分 200、启用/禁用用户 200、资源上下架 200,审计日志正确记录;全路由文件 AST 扫描确认无其他缺失导入 |
| 全功能端到端审计 | 七大生成流(课件/动画/练习/教案/思维导图/命题/作文) × 全生命周期(生成→保存→列表→详情→导出→改编→删除);课件分享链路(token→公开访问→列表→撤销→404);社交链路(发帖→评论→点赞→收藏→搜索5维度);AI对话(创建→发消息→历史加载→删除);课堂数据回收(创建→发布→4学生公开提交→分析统计→Excel导出);密码安全(改密/旧密码校验/新旧不同/强度策略);材料解析(文件上传→解析→入库);管理后台(看板15指标/用户CRUD/资源审核/审计日志50条) |
## 待完善(持续打磨)
| 能力 | 建议实现 |
| --- | --- |
| 模板库持续扩展 | 持续补充学科/题型覆盖(当前 36 动画 + 20 游戏),打磨生成与模板质量 |
| 安全增强 | 余项:病毒扫描集成;✅ JWT 密钥安全已落地(SECRET_KEY 启动校验:生产模式拒绝弱/默认/空密钥,开发模式生成临时密钥并警告;当前 .env 已配置 64 字符强密钥;旧默认密钥伪造的 token 端到端验证被拒);密码强度策略已覆盖注册/改密/重置,刷新令牌限流 30/min 已落地 |
## 开发优先级
1. 材料解析、数据回收学生端、积分体系和管理后台已落地,持续打磨生成质量。
2. ✅ 课件编辑器组件化(主题模板 + 模板插入)已落地。
3. ✅ 动画/游戏模板库(36 + 20)已落地,覆盖数学/物理/化学/生物/地理/历史/语文/英语/通用多学科场景;并修复 3 个旧游戏模板的脚本错误。
4. ✅ 生产安全(上传校验 + 审计日志 + 修改密码)已落地,持续扩展审计覆盖与安全增强。
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+76
View File
@@ -0,0 +1,76 @@
const { chromium } = require("playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => {
if (msg.type() === "error") errors.push(`CONSOLE: ${msg.text()}`);
});
page.on("pageerror", err => errors.push(`PAGEERROR: ${err.message}`));
const BASE = "http://127.0.0.1:5175";
// Login first via API
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
headers: { "Content-Type": "application/json" },
});
const loginData = await loginResp.json();
const token = loginData.access_token;
// Inject token into localStorage
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => {
localStorage.setItem("token", t);
localStorage.setItem("access_token", t);
localStorage.setItem("zj_token", t);
}, token);
const routes = [
"/", "/courseware", "/courseware/create", "/animation", "/exercise",
"/lesson-plan", "/exam", "/essay-grade", "/mindmap", "/materials",
"/resources", "/community", "/classroom", "/assistant", "/profile",
"/admin", "/search",
];
for (const route of routes) {
const routeErrors = [];
page.removeAllListeners("console");
page.removeAllListeners("pageerror");
page.on("console", msg => { if (msg.type() === "error") routeErrors.push(msg.text()); });
page.on("pageerror", err => routeErrors.push(err.message));
try {
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 15000 });
await page.waitForTimeout(1500);
// Check for empty/blank pages
const bodyText = await page.evaluate(() => document.body.innerText.trim());
const isEmpty = bodyText.length < 10;
const hasRouterView = await page.evaluate(() => {
const app = document.querySelector("#app");
return app && app.children.length > 0;
});
// Filter expected errors (401/403 for admin views)
const realErrors = routeErrors.filter(e =>
!e.includes("401") && !e.includes("403") &&
!e.includes("Failed to fetch") &&
!e.includes("NetworkError")
);
const status = realErrors.length === 0 && !isEmpty && hasRouterView ? "OK" : "CHECK";
const errSummary = realErrors.length > 0 ? ` ERR:[${realErrors.slice(0,2).join(" | ").substring(0,100)}]` : "";
const emptyFlag = isEmpty ? " BLANK" : "";
console.log(`${status} ${route.padEnd(20)} ${bodyText.length}chars${emptyFlag}${errSummary}`);
} catch (e) {
console.log(`FAIL ${route.padEnd(20)} ${e.message.substring(0, 80)}`);
}
}
await browser.close();
})();
+58
View File
@@ -0,0 +1,58 @@
import { chromium } from 'playwright-core';
import fs from 'fs';
import path from 'path';
const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
});
const loginData = await loginRes.json();
const accessToken = loginData.access_token;
const refreshToken = loginData.refresh_token;
const browser = await chromium.launch({
channel: 'chrome', headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(([at, rt]) => {
localStorage.setItem('access_token', at);
localStorage.setItem('refresh_token', rt);
}, [accessToken, refreshToken]);
const page = await ctx.newPage();
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
const shotDir = path.resolve('audit_shots');
if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir);
const pages = [
{ name: 'home', path: '/home' },
{ name: 'courseware_list', path: '/courseware' },
{ name: 'courseware_create', path: '/courseware/create' },
{ name: 'animation', path: '/animation' },
{ name: 'exercise', path: '/exercise' },
{ name: 'exam', path: '/exam' },
{ name: 'lesson_plan', path: '/lesson-plan' },
{ name: 'mindmap', path: '/mindmap' },
{ name: 'essay_grade', path: '/essay-grade' },
{ name: 'materials', path: '/materials' },
{ name: 'profile', path: '/profile' },
{ name: 'classroom', path: '/classroom' },
{ name: 'assistant', path: '/assistant' },
{ name: 'admin', path: '/admin' },
];
for (const p of pages) {
await page.goto('http://127.0.0.1:5175' + p.path, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(shotDir, p.name + '.png'), fullPage: false });
const url = page.url();
const redirected = url.includes('login');
const h = await page.textContent('h1, h2').catch(() => '');
console.log('[' + p.name + '] ' + (redirected ? 'REDIRECT_LOGIN' : url.split('/').slice(-2).join('/')) + ' h="' + (h||'').trim().substring(0,30) + '"');
}
console.log('ERRORS:', errors.length);
errors.slice(0,10).forEach(e => console.log(' ', e.substring(0,140)));
await browser.close();
+73
View File
@@ -0,0 +1,73 @@
const { chromium } = require("playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// === TEST 1: Animation template gallery ===
console.log("=== ANIMATION TEMPLATES ===");
await page.goto(BASE + "/animation", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
const tplButtons = await page.$$(".tpl-btn");
console.log("template buttons found:", tplButtons.length);
// Click first template
if (tplButtons.length > 0) {
await tplButtons[0].click();
await page.waitForTimeout(2000);
const previewIframe = await page.$(".tg-preview-wrap iframe, .preview-wrap iframe, iframe");
if (previewIframe) {
console.log("preview iframe: FOUND");
} else {
console.log("preview iframe: NOT FOUND");
const allIframes = await page.$$("iframe");
console.log("total iframes:", allIframes.length);
}
const cards = await page.$$(".tg-card, .result-card");
console.log("result cards:", cards.length);
}
// === TEST 2: Exercise template gallery ===
console.log("\n=== EXERCISE TEMPLATES ===");
await page.goto(BASE + "/exercise", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
const exTplBtns = await page.$$(".tpl-btn");
console.log("exercise template buttons:", exTplBtns.length);
// === TEST 3: Courseware create page ===
console.log("\n=== COURSEWARE CREATE ===");
await page.goto(BASE + "/courseware/create", { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const textareas = await page.$$("textarea, input[type=text], .el-input__inner");
console.log("input fields:", textareas.length);
const genButtons = await page.$$("button:has-text('生成'), button:has-text('创建'), .gen-btn");
console.log("generate buttons:", genButtons.length);
// === TEST 4: Classroom ===
console.log("\n=== CLASSROOM ===");
await page.goto(BASE + "/classroom", { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const classroomText = await page.evaluate(() => document.body.innerText.substring(0, 500));
console.log("classroom has content:", classroomText.length > 50);
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\n=== TOTAL ERRORS (filtered):", realErrors.length, "===");
realErrors.slice(0, 5).forEach(e => console.log(" ", e.substring(0, 120)));
await browser.close();
})();
+74
View File
@@ -0,0 +1,74 @@
const { chromium } = require("playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// Get existing courseware list
const cwResp = await page.request.get("http://127.0.0.1:8010/api/coursewares", {
headers: { Authorization: `Bearer ${token}` },
});
const coursewares = await cwResp.json();
console.log("existing coursewares:", coursewares.length);
if (coursewares.length > 0) {
console.log("first courseware id:", coursewares[0].id, "title:", coursewares[0].title?.substring(0, 30));
}
// Navigate to courseware list and check if preview works
console.log("\n=== COURSEWARE LIST ===");
await page.goto(BASE + "/courseware", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
// Count cards
const cards = await page.$$(".courseware-card, .cw-card, .el-card");
console.log("courseware cards on page:", cards.length);
// Check for action buttons
const previewBtns = await page.$$("button:has-text('预览'), button:has-text('查看'), a:has-text('预览')");
console.log("preview buttons:", previewBtns.length);
// Navigate to first courseware preview if exists
if (coursewares.length > 0) {
console.log("\n=== COURSEWARE PREVIEW ===");
await page.goto(`${BASE}/courseware/${coursewares[0].id}/preview`, { waitUntil: "networkidle" });
await page.waitForTimeout(3000);
// Check if slides rendered
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
const slideContent = await page.evaluate(() => {
const slide = document.querySelector(".slide-container, .preview-slide, .cw-slide");
return slide ? "slide found" : "no slide element";
});
console.log("slide element:", slideContent);
// Check page navigation
const navBtns = await page.$$(".page-nav, .nav-btn, button:has-text('下一页'), button:has-text('上一页')");
console.log("nav buttons:", navBtns.length);
// Take screenshot
await page.screenshot({ path: "_cw_preview.png" });
console.log("screenshot saved");
}
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\nerrors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+66
View File
@@ -0,0 +1,66 @@
const { chromium } = require("playwright-core");
const fs = require("fs");
(async () => {
const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
console.log("testing courseware preview for id:", cwId);
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// Go to preview
await page.goto(`${BASE}/courseware/${cwId}/preview`, { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(3000);
// Check rendering
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
const bodyText = await page.evaluate(() => document.body.innerText.substring(0, 200));
console.log("body text preview:", bodyText.substring(0, 120));
// Check for slide/navigation elements
const allDivs = await page.$$("#app div");
console.log("total divs:", allDivs.length);
// Check for specific preview elements
const slideEls = await page.$$("[class*='slide'], [class*='preview'], [class*='page-nav'], [class*='cw-']");
console.log("slide/preview elements:", slideEls.length);
// Look for iframe content
for (let i = 0; i < Math.min(iframes.length, 3); i++) {
try {
const frame = iframes[i].contentFrame();
if (frame) {
const content = await frame.evaluate(() => document.body ? document.body.innerText.substring(0, 60) : "empty");
console.log(`iframe[${i}] content:`, content);
}
} catch(e) {
console.log(`iframe[${i}] error:`, e.message.substring(0, 60));
}
}
// Screenshot
await page.screenshot({ path: "_cw_preview.png" });
console.log("screenshot saved");
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\nerrors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+67
View File
@@ -0,0 +1,67 @@
const { chromium } = require("playwright-core");
const fs = require("fs");
(async () => {
const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
console.log("testing courseware preview at /preview/" + cwId);
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// Correct URL: /preview/:id
await page.goto(`${BASE}/preview/${cwId}`, { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(4000);
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
// Check iframe content
for (let i = 0; i < Math.min(iframes.length, 2); i++) {
try {
const frame = iframes[i].contentFrame();
if (frame) {
const html = await frame.evaluate(() => document.documentElement.outerHTML.substring(0, 200));
console.log(`iframe[${i}] html:`, html.substring(0, 150));
}
} catch(e) {
console.log(`iframe[${i}] error:`, e.message.substring(0, 80));
}
}
// Check page navigation
const navBtns = await page.$$("button");
const navTexts = [];
for (const btn of navBtns) {
const text = await btn.textContent();
if (text.trim()) navTexts.push(text.trim().substring(0, 20));
}
console.log("buttons:", navTexts.slice(0, 10));
// Page counter
const bodyText = await page.evaluate(() => document.body.innerText);
const pageMatch = bodyText.match(/(\d+)\s*\/\s*(\d+)/);
if (pageMatch) console.log("page counter:", pageMatch[0]);
await page.screenshot({ path: "_cw_preview2.png" });
console.log("screenshot saved");
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\nerrors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+55
View File
@@ -0,0 +1,55 @@
const { chromium } = require("playwright-core");
const fs = require("fs");
(async () => {
const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
await page.goto(`${BASE}/preview/${cwId}`, { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(4000);
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
// Check main slide iframe content via srcdoc
const slideIframe = await page.$(".slide-raw-iframe");
if (slideIframe) {
const srcdoc = await slideIframe.getAttribute("srcdoc");
console.log("slide srcdoc length:", srcdoc ? srcdoc.length : 0);
if (srcdoc) console.log("srcdoc has content:", srcdoc.length > 100);
} else {
console.log("no .slide-raw-iframe found");
}
// Check page counter
const pageCounter = await page.$eval(".bottom-page-info, .ov-page", el => el.textContent).catch(() => "none");
console.log("page counter:", pageCounter);
// Check thumbnails
const thumbs = await page.$$(".thumb-raw-iframe, .thumb-scaler");
console.log("thumbnails:", thumbs.length);
await page.screenshot({ path: "_cw_preview3.png" });
console.log("screenshot saved");
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("errors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+35
View File
@@ -0,0 +1,35 @@
const { chromium } = require('playwright-core');
(async () => {
const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERR: ' + e.message));
// 登录
await page.goto('http://127.0.0.1:5173/login', { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
await page.fill('input[placeholder="请输入手机号"]', '13943441149');
await page.fill('input[type="password"]', 'Test1234');
await page.click('button.gradient-btn');
await page.waitForTimeout(3000);
const url1 = page.url();
console.log('登录后URL:', url1);
// 思维导图
await page.goto('http://127.0.0.1:5173/mindmap', { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
await page.fill('input[placeholder*="光合作用"]', '中国历史朝代');
await page.waitForTimeout(400);
await page.click('button:has-text("生成导图")');
console.log('已点生成导图');
await page.waitForTimeout(9000);
await page.screenshot({ path: 'D:\\AI\\jiaoyu\\_shot_mindmap.png', fullPage: true });
const bodyText = await page.evaluate(() => document.body.innerText);
console.log('含先秦文明:', bodyText.includes('先秦文明'));
console.log('含秦汉大一统:', bodyText.includes('秦汉大一统'));
console.log('含隋唐盛世:', bodyText.includes('隋唐盛世'));
console.log('含概念定义(旧通用):', bodyText.includes('概念定义'));
console.log('含知识结构(旧通用):', bodyText.includes('知识结构'));
console.log('错误数:', errors.length);
if (errors.length) console.log('错误:', errors.slice(0,5).join(' | '));
await browser.close();
})();
+22
View File
@@ -0,0 +1,22 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const errs = [];
page.on('console', m => errs.push(m.type()+': '+m.text()));
page.on('pageerror', e => errs.push('PAGEERR: '+e.message));
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).data?.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(3000);
console.log('URL:', page.url());
console.log('has tpl-btn:', await page.locator('.tpl-btn').count());
console.log('body text (first 200):', (await page.locator('body').innerText()).slice(0,200));
console.log('errors:', errs.slice(0,8));
await page.screenshot({ path: '../audit_shots/_debug_page.png' });
await browser.close();
})();
+29
View File
@@ -0,0 +1,29 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
page.on('pageerror', e => console.log('PAGEERROR:', e.message, '\nSTACK:', e.stack));
page.on('console', m => { if (m.type()==='error') console.log('CONSOLE_ERR:', m.text()); });
// Load the module and render probability template
await page.goto('http://127.0.0.1:5175/animation');
// we need the template render output; use the gallery instead but isolate
await page.waitForTimeout(1500);
// inject token
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).data?.access_token;
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(2000);
// open gallery and jump directly to probability card
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1200);
// search for probability
await page.locator('.tg-search input').fill('概率');
await page.waitForTimeout(600);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(2000);
console.log('done');
await browser.close();
})();
+46
View File
@@ -0,0 +1,46 @@
import { chromium } from 'playwright-core';
import fs from 'fs';
import path from 'path';
const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
});
const loginData = await loginRes.json();
const browser = await chromium.launch({
channel: 'chrome', headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(([at, rt]) => {
localStorage.setItem('access_token', at);
localStorage.setItem('refresh_token', rt);
}, [loginData.access_token, loginData.refresh_token]);
const page = await ctx.newPage();
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// Go to home and wait longer
await page.goto('http://127.0.0.1:5175/home', { waitUntil: 'networkidle', timeout: 20000 }).catch(e => console.log('goto err', e.message));
await page.waitForTimeout(4000);
// Diagnose
const info = await page.evaluate(() => {
const body = document.body;
return {
bodyTextLen: body.innerText.length,
bodyTextHead: body.innerText.substring(0, 300),
url: location.href,
hasH1: !!document.querySelector('h1'),
h1Text: document.querySelector('h1')?.textContent || '(none)',
mainContent: document.querySelector('.home-page, .home-container, .hero, main, #app')?.innerHTML?.length || 0,
childCount: document.querySelector('#app')?.children?.length || 0,
};
});
console.log(JSON.stringify(info, null, 2));
await page.screenshot({ path: path.resolve('audit_shots/home2.png') });
console.log('ERRORS:', errors.length);
errors.slice(0,8).forEach(e => console.log(' ', e.substring(0,160)));
await browser.close();
+15
View File
@@ -0,0 +1,15 @@
import { chromium } from 'playwright-core';
import fs from 'fs';
import path from 'path';
const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13943441149', password: 'Test1234' }) });
const loginData = await loginRes.json();
const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(([at, rt]) => { localStorage.setItem('access_token', at); localStorage.setItem('refresh_token', rt); }, [loginData.access_token, loginData.refresh_token]);
const page = await ctx.newPage();
await page.goto('http://127.0.0.1:5175/', { waitUntil: 'networkidle', timeout: 20000 }).catch(e => console.log('goto err', e.message));
await page.waitForTimeout(4000);
const info = await page.evaluate(() => ({ bodyTextLen: document.body.innerText.length, bodyTextHead: document.body.innerText.substring(0, 200), url: location.href, h1Text: document.querySelector('h1')?.textContent || '(none)', childCount: document.querySelector('#app')?.children?.length || 0 }));
console.log(JSON.stringify(info, null, 2));
await page.screenshot({ path: path.resolve('audit_shots/home_root.png') });
await browser.close();
+43
View File
@@ -0,0 +1,43 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errors = [], warnings = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); if (m.type()==='warning' && m.text().includes('resolve component')) warnings.push(m.text()); });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const j = await r.json();
const tok = j.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
const cards = await page.locator('.tg-card').count();
const ours = ['勾股定理','单位圆','排序算法','DNA','牛顿第二','板块构造'];
const results = [];
for (let i = 0; i < cards; i++) {
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(800);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
if (ours.some(k => name.includes(k))) {
const frame = page.frameLocator('.tg-preview-wrap iframe').first();
let ok = false, iconOk = false;
try { ok = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
// check the card icon rendered (svg present)
try { iconOk = await page.locator('.tg-card').nth(i).locator('svg').count() > 0; } catch(e) {}
results.push({ name, ok, iconOk });
}
}
await page.screenshot({ path: '../audit_shots/_tpl_final2.png' });
console.log('TOTAL_CARDS:', cards);
console.log('CONSOLE_ERRORS:', errors.length, 'ICON_WARNINGS:', warnings.length);
if (errors.length) console.log('ERROR_SAMPLES:', errors.slice(0,5));
if (warnings.length) console.log('WARN_SAMPLES:', warnings.slice(0,3));
console.log('NEW_TEMPLATES:', JSON.stringify(results));
console.log('ALL_NEW_OK:', results.length === 6 && results.every(r => r.ok && r.iconOk));
await browser.close();
})();
+30
View File
@@ -0,0 +1,30 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/exercise');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
const cards = await page.locator('.tg-card').count();
const ours = ['知识分类','序列记忆','成语接龙','定时炸弹','入门数独'];
const bad = [];
for (let i = 0; i < cards; i++) {
const errs = [];
const handler = e => errs.push(e.message);
page.on('pageerror', handler);
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(1000);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
page.off('pageerror', handler);
if (errs.length) bad.push({ i, name, errs: errs.slice(0,2) });
}
console.log('BAD_GAMES:', JSON.stringify(bad, null, 2));
await browser.close();
})();
+32
View File
@@ -0,0 +1,32 @@
const { chromium } = require('playwright-core');
const fs = require('fs');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const base = 'http://127.0.0.1:8010';
const loginRes = await fetch(base + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13943441149', password: 'Test1234' }) });
const loginData = await loginRes.json();
const token = loginData.data?.access_token || loginData.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', 'r'); }, token);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(1800);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1200);
const cards = await page.locator('.tg-card').count();
const bad = [];
for (let i = 0; i < cards; i++) {
let beforeErr = 0;
const errs = [];
const handler = e => errs.push(e.message);
page.on('pageerror', handler);
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(700);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
page.off('pageerror', handler);
if (errs.length) bad.push({ i, name, errs: errs.slice(0,2) });
}
console.log('BAD_TEMPLATES:', JSON.stringify(bad, null, 2));
await browser.close();
})();

Some files were not shown because too many files have changed in this diff Show More