智教助手平台:完整初始化
- 前端: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:
+53
@@ -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
|
||||
@@ -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:8000,API文档 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 |
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)}"},
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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": "密码重置成功"}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 *
|
||||
@@ -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
|
||||
@@ -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] = []
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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="新密码")
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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="每日登录领取积分",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
@@ -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": "错误信息描述"
|
||||
}
|
||||
```
|
||||
@@ -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防护
|
||||
- 文件上传安全检查
|
||||
- 数据库参数化查询
|
||||
- 敏感信息加密存储
|
||||
@@ -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 三个端点全部 500(NameError)。修复后端到端验证:发放积分 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. ✅ 生产安全(上传校验 + 审计日志 + 修改密码)已落地,持续扩展审计覆盖与安全增强。
|
||||
@@ -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;"]
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
Reference in New Issue
Block a user