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 # Service
CH_HOST=0.0.0.0 CH_HOST=0.0.0.0
CH_PORT=8000 CH_PORT=8000
CH_DEBUG=true CH_DEBUG=false
# Database # Database (Docker 部署时使用 volume 路径)
CH_DATABASE_URL=sqlite+aiosqlite:///./classhub.db CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db
# JWT # JWT (务必修改 secret)
CH_JWT_SECRET=change-me-in-production CH_JWT_SECRET=change-me-in-production
CH_JWT_EXPIRY_HOURS=72 CH_JWT_EXPIRY_HOURS=72
# Tencent COS # Tencent COS (对象存储)
CH_COS_SECRET_ID=AKIDUxR3TfeWDbqFQQDn9INJ1U8annY7TbWN CH_COS_SECRET_ID=your-cos-secret-id
CH_COS_SECRET_KEY=jJitIUTFyf5WvDPXiigS2PaaMtCJSQCn CH_COS_SECRET_KEY=your-cos-secret-key
CH_COS_REGION=ap-guangzhou CH_COS_REGION=ap-guangzhou
CH_COS_BUCKET=hku-icb-1311994147 CH_COS_BUCKET=your-bucket-name
CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com
# SMTP Email # SMTP Email (邮件通知)
CH_SMTP_HOST=gz-smtp.qcloudmail.com CH_SMTP_HOST=smtp.example.com
CH_SMTP_PORT=465 CH_SMTP_PORT=465
CH_SMTP_USER=noreply@hkuicb.info CH_SMTP_USER=noreply@example.com
CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69 CH_SMTP_PASSWORD=your-smtp-password
CH_SMTP_FROM_EMAIL=noreply@hkuicb.info CH_SMTP_FROM_EMAIL=noreply@example.com
CH_SMTP_FROM_NAME=HKU ICB Class Hub CH_SMTP_FROM_NAME=HKU ICB Class Hub
# Frontend URL # Frontend URL (CORS 用)
CH_FRONTEND_URL=http://localhost:3000 CH_FRONTEND_URL=http://your-server-ip
# Super Admin Seed # Super Admin Seed (首次启动自动创建)
CH_SUPER_ADMIN_EMAIL=admin@hkuicb.info CH_SUPER_ADMIN_EMAIL=admin@example.com
CH_SUPER_ADMIN_PASSWORD=admin123 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 fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.deps import require_role from app.core.deps import require_role
from app.db.database import get_db 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 ( from app.schemas.assignment import (
AssignmentCreate, AssignmentUpdate, AssignmentOut, AssignmentCreate, AssignmentUpdate, AssignmentOut,
AssignmentDetailOut, SubmissionGrade, SubmissionOut, AssignmentDetailOut, SubmissionGrade, SubmissionOut,
@ -26,7 +28,15 @@ from app.services.cos_service import upload_file
router = APIRouter(prefix="/api/assignments", tags=["assignments"]) 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 submission_count = len(a.submissions) if a.submissions else 0
my_submitted = any(s.student_id == user_id for s in (a.submissions or [])) my_submitted = any(s.student_id == user_id for s in (a.submissions or []))
return AssignmentOut( return AssignmentOut(
@ -40,6 +50,7 @@ def _build_assignment_out(a: any, user_id: int) -> AssignmentOut:
attachment_urls=a.get_attachment_urls_list(), attachment_urls=a.get_attachment_urls_list(),
status=a.status, status=a.status,
submission_count=submission_count, submission_count=submission_count,
total_members=total_members,
my_submitted=my_submitted, my_submitted=my_submitted,
created_at=a.created_at, created_at=a.created_at,
updated_at=a.updated_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) assignments, total = await list_assignments(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // 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) 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") raise HTTPException(status_code=400, detail="You are not assigned to a class")
assignment = await create_assignment(db, effective_class_id, user.id, data) 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") @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: if user.role != "super_admin" and assignment.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied") 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 # Student only sees their own submission
if user.role == "student": if user.role == "student":
@ -168,7 +181,7 @@ async def update_existing_assignment(
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
updated = await update_assignment(db, assignment, data) 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}") @router.delete("/{assignment_id}")

View File

@ -53,6 +53,7 @@ class AssignmentOut(BaseModel):
attachment_urls: list[str] | None attachment_urls: list[str] | None
status: str status: str
submission_count: int submission_count: int
total_members: int
my_submitted: bool my_submitted: bool
created_at: datetime created_at: datetime
updated_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";
}
}