This commit is contained in:
aaron 2026-04-11 21:15:42 +08:00
parent 657a06778f
commit 85f6b7e42b
8 changed files with 104 additions and 16 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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

View File

@ -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),
):

View File

@ -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"<p>{content_preview}</p>" if content_preview else None,
email_action_path="/announcements",
)
return announcement

View File

@ -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"""
<div style="margin-top: 20px;">
<a href="{action_url}" style="display:inline-block;padding:10px 24px;background:#111827;color:#fff;border-radius:6px;text-decoration:none;font-size:14px;">
查看详情
</a>
</div>
"""
html = f"""
<div style="max-width:600px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;">
<div style="padding:24px 0;border-bottom:2px solid #111827;">
<h1 style="margin:0;font-size:20px;color:#111827;">HKU ICB</h1>
</div>
<div style="padding:24px 0;">
<h2 style="margin:0 0 12px;font-size:18px;">{title}</h2>
<div style="font-size:14px;line-height:1.6;color:#4b5563;">{body}</div>
{action_html}
</div>
<div style="padding:16px 0;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;">
此邮件由系统自动发送请勿直接回复
</div>
</div>
"""
for email in emails:
await send_email(email, subject, html)

View File

@ -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

View File

@ -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"<p><strong>{data.title}</strong></p><p>时间: {time_str}{location_info}</p>",
email_action_path="/schedule",
)
return item

View File

@ -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"<p>{content_preview}</p>" if content_preview else None,
email_action_path="/timeline",
)
return post