This commit is contained in:
aaron 2026-04-12 17:49:03 +08:00
parent a39fd6186c
commit 8a35c0b97f
7 changed files with 137 additions and 25 deletions

10
backend/.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
*.pyo
.env
*.db
.git
.gitignore
*.md
alembic/versions/

View File

@ -1,33 +1,33 @@
# Service
CH_HOST=0.0.0.0
CH_PORT=8000
CH_DEBUG=true
CH_DEBUG=false
# Database
CH_DATABASE_URL=sqlite+aiosqlite:///./classhub.db
# Database (Docker 部署时使用 volume 路径)
CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db
# JWT
# JWT (务必修改 secret)
CH_JWT_SECRET=change-me-in-production
CH_JWT_EXPIRY_HOURS=72
# Tencent COS
CH_COS_SECRET_ID=AKIDUxR3TfeWDbqFQQDn9INJ1U8annY7TbWN
CH_COS_SECRET_KEY=jJitIUTFyf5WvDPXiigS2PaaMtCJSQCn
# Tencent COS (对象存储)
CH_COS_SECRET_ID=your-cos-secret-id
CH_COS_SECRET_KEY=your-cos-secret-key
CH_COS_REGION=ap-guangzhou
CH_COS_BUCKET=hku-icb-1311994147
CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com
CH_COS_BUCKET=your-bucket-name
CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com
# SMTP Email
CH_SMTP_HOST=gz-smtp.qcloudmail.com
# SMTP Email (邮件通知)
CH_SMTP_HOST=smtp.example.com
CH_SMTP_PORT=465
CH_SMTP_USER=noreply@hkuicb.info
CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69
CH_SMTP_FROM_EMAIL=noreply@hkuicb.info
CH_SMTP_USER=noreply@example.com
CH_SMTP_PASSWORD=your-smtp-password
CH_SMTP_FROM_EMAIL=noreply@example.com
CH_SMTP_FROM_NAME=HKU ICB Class Hub
# Frontend URL
CH_FRONTEND_URL=http://localhost:3000
# Frontend URL (CORS 用)
CH_FRONTEND_URL=http://your-server-ip
# Super Admin Seed
CH_SUPER_ADMIN_EMAIL=admin@hkuicb.info
CH_SUPER_ADMIN_PASSWORD=admin123
# Super Admin Seed (首次启动自动创建)
CH_SUPER_ADMIN_EMAIL=admin@example.com
CH_SUPER_ADMIN_PASSWORD=change-me-please

17
backend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.13-slim
WORKDIR /app
# Install dependencies first (layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory for SQLite
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.deps import require_role
from app.db.database import get_db
from app.db.models import User
from app.db.models import User, Class_
from app.schemas.assignment import (
AssignmentCreate, AssignmentUpdate, AssignmentOut,
AssignmentDetailOut, SubmissionGrade, SubmissionOut,
@ -26,7 +28,15 @@ from app.services.cos_service import upload_file
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
def _build_assignment_out(a: any, user_id: int) -> AssignmentOut:
async def _get_roster_count(db: AsyncSession, class_id: int) -> int:
from app.db.models import StudentRoster
result = await db.execute(
select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id)
)
return result.scalar() or 0
def _build_assignment_out(a: any, user_id: int, total_members: int = 0) -> AssignmentOut:
submission_count = len(a.submissions) if a.submissions else 0
my_submitted = any(s.student_id == user_id for s in (a.submissions or []))
return AssignmentOut(
@ -40,6 +50,7 @@ def _build_assignment_out(a: any, user_id: int) -> AssignmentOut:
attachment_urls=a.get_attachment_urls_list(),
status=a.status,
submission_count=submission_count,
total_members=total_members,
my_submitted=my_submitted,
created_at=a.created_at,
updated_at=a.updated_at,
@ -79,7 +90,8 @@ async def get_assignments(
assignments, total = await list_assignments(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
items = [_build_assignment_out(a, user.id) for a in assignments]
roster_count = await _get_roster_count(db, effective_class_id)
items = [_build_assignment_out(a, user.id, roster_count) for a in assignments]
return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
@ -95,7 +107,8 @@ async def create_new_assignment(
raise HTTPException(status_code=400, detail="You are not assigned to a class")
assignment = await create_assignment(db, effective_class_id, user.id, data)
return _build_assignment_out(assignment, user.id)
roster_count = await _get_roster_count(db, effective_class_id)
return _build_assignment_out(assignment, user.id, roster_count)
@router.post("/{assignment_id}/attachments")
@ -138,7 +151,7 @@ async def get_assignment_detail(
if user.role != "super_admin" and assignment.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied")
base = _build_assignment_out(assignment, user.id)
base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id))
# Student only sees their own submission
if user.role == "student":
@ -168,7 +181,7 @@ async def update_existing_assignment(
raise HTTPException(status_code=403, detail="Access denied")
updated = await update_assignment(db, assignment, data)
return _build_assignment_out(updated, user.id)
return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id))
@router.delete("/{assignment_id}")

View File

@ -53,6 +53,7 @@ class AssignmentOut(BaseModel):
attachment_urls: list[str] | None
status: str
submission_count: int
total_members: int
my_submitted: bool
created_at: datetime
updated_at: datetime

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
services:
backend:
build: ./backend
restart: unless-stopped
env_file: ./backend/.env
environment:
- CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db
- CH_FRONTEND_URL=http://localhost
volumes:
- classhub-data:/app/data
expose:
- "8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 5s
retries: 3
frontend:
build:
context: ./frontend
args:
- NEXT_PUBLIC_API_URL=
restart: unless-stopped
expose:
- "3000"
depends_on:
- backend
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- frontend
volumes:
classhub-data:

29
nginx/nginx.conf Normal file
View File

@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
client_max_body_size 20m;
# API requests backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Next.js HMR (hot module replacement) WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}