diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bc4d8bc..1be01db 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,10 @@ "Bash(npx shadcn@latest add:*)", "Bash(npm --prefix /Users/aaron/source_code/hku-icb-class/frontend run build)", "Bash(/Users/aaron/source_code/hku-icb-class/frontend/node_modules/.bin/tsc --noEmit --project /Users/aaron/source_code/hku-icb-class/frontend/tsconfig.json)", - "Bash(test:*)" + "Bash(test:*)", + "Bash(/Users/aaron/source_code/hku-icb-class/backend/.venv/bin/python:*)", + "Bash(.venv/bin/python:*)", + "Bash(backend/.venv/bin/pip show:*)" ] } } diff --git a/backend/.env.example b/backend/.env.example index c3a1fde..c3da64b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,7 +20,7 @@ CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com # SMTP Email CH_SMTP_HOST=gz-smtp.qcloudmail.com CH_SMTP_PORT=465 -CH_SMTP_USER=noreply +CH_SMTP_USER=noreply@hkuicb.info CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69 CH_SMTP_FROM_EMAIL=noreply@hkuicb.info CH_SMTP_FROM_NAME=HKU ICB Class Hub diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py index e3f231f..6d8f83d 100644 --- a/backend/app/api/resources.py +++ b/backend/app/api/resources.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import require_role @@ -76,11 +76,11 @@ async def get_resources( @router.post("/", response_model=ResourceOut) async def upload_new_resource( - title: str, - category: str, file: UploadFile = File(...), - description: str | None = None, - class_id: int | None = None, + title: str = Form(...), + category: str = Form(...), + description: str | None = Form(None), + class_id: int | None = Form(None), user: User = Depends(require_role("super_admin", "class_admin")), db: AsyncSession = Depends(get_db), ): diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py index 424a849..2e831d2 100644 --- a/backend/app/services/announcement_service.py +++ b/backend/app/services/announcement_service.py @@ -21,10 +21,15 @@ async def create_announcement( await db.commit() await db.refresh(announcement) - # Send notifications to class members + # Send notifications + email to class members + content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") await create_notifications_for_class( db, class_id, "announcement", f"新公告: {data.title}", - related_id=announcement.id, exclude_user_id=author_id, + content=content_preview, + related_id=announcement.id, + email_subject=f"HKU ICB - 新公告: {data.title}", + email_body=f"

{content_preview}

" if content_preview else None, + email_action_path="/announcements", ) return announcement diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 43b4cb4..28f0b20 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -57,3 +57,39 @@ async def send_approval_notification(student_email: str, approved: bool): await send_email( student_email, f"HKU ICB: Registration {status_text.capitalize()}", html ) + + +async def send_class_notification_email( + emails: list[str], + subject: str, + title: str, + body: str, + action_url: str | None = None, +): + """Send a styled notification email to class members.""" + action_html = "" + if action_url: + action_html = f""" +
+ + 查看详情 + +
+ """ + html = f""" +
+
+

HKU ICB

+
+
+

{title}

+
{body}
+ {action_html} +
+
+ 此邮件由系统自动发送,请勿直接回复。 +
+
+ """ + for email in emails: + await send_email(email, subject, html) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 697941c..f379b8a 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,7 +1,13 @@ +import asyncio +import logging + from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Notification, User +from app.services.email_service import send_class_notification_email + +logger = logging.getLogger(__name__) async def create_notification( @@ -33,19 +39,21 @@ async def create_notifications_for_class( content: str | None = None, related_id: int | None = None, exclude_user_id: int | None = None, + email_subject: str | None = None, + email_body: str | None = None, + email_action_path: str | None = None, ): - """Create notifications for all approved users in a class.""" + """Create in-app notifications + send email for all approved users in a class.""" result = await db.execute( - select(User.id).where( + select(User.id, User.email).where( User.class_id == class_id, User.status == "approved", ) ) - user_ids = [row[0] for row in result.all()] + rows = result.all() - for uid in user_ids: - if exclude_user_id and uid == exclude_user_id: - continue + emails: list[str] = [] + for uid, email in rows: notification = Notification( user_id=uid, type=type, @@ -54,9 +62,26 @@ async def create_notifications_for_class( related_id=related_id, ) db.add(notification) + emails.append(email) await db.commit() + # Send email notification in background (fire-and-forget) + if email_subject and emails: + from app.config import settings + action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None + asyncio.create_task(_safe_send_emails(emails, email_subject, title, email_body or content or "", action_url)) + + +async def _safe_send_emails( + emails: list[str], subject: str, title: str, body: str, action_url: str | None +): + """Fire-and-forget email sending with error logging.""" + try: + await send_class_notification_email(emails, subject, title, body, action_url) + except Exception as e: + logger.error(f"Failed to send class notification emails: {e}") + async def list_notifications( db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20 diff --git a/backend/app/services/schedule_service.py b/backend/app/services/schedule_service.py index 49a5f59..e7f4a60 100644 --- a/backend/app/services/schedule_service.py +++ b/backend/app/services/schedule_service.py @@ -19,10 +19,16 @@ async def create_schedule( await db.commit() await db.refresh(item) - # Send notifications to class members + # Send notifications + email to class members + time_str = data.start_time.strftime("%Y-%m-%d %H:%M") + location_info = f" · {data.location}" if data.location else "" await create_notifications_for_class( db, class_id, "schedule", f"新排期: {data.title}", + content=f"{time_str}{location_info}", related_id=item.id, + email_subject=f"HKU ICB - 新排期: {data.title}", + email_body=f"

{data.title}

时间: {time_str}{location_info}

", + email_action_path="/schedule", ) return item diff --git a/backend/app/services/timeline_service.py b/backend/app/services/timeline_service.py index 0aef37d..c092e3b 100644 --- a/backend/app/services/timeline_service.py +++ b/backend/app/services/timeline_service.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import selectinload from app.db.models import Timeline, User from app.schemas.timeline import TimelineCreate, TimelineUpdate +from app.services.notification_service import create_notifications_for_class async def create_timeline( @@ -21,6 +22,18 @@ async def create_timeline( db.add(post) await db.commit() await db.refresh(post) + + # Send notifications + email to class members + content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") + await create_notifications_for_class( + db, class_id, "timeline", f"新大事件: {data.title}", + content=content_preview, + related_id=post.id, + email_subject=f"HKU ICB - 新大事件: {data.title}", + email_body=f"

{content_preview}

" if content_preview else None, + email_action_path="/timeline", + ) + return post