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(npx shadcn@latest add:*)",
"Bash(npm --prefix /Users/aaron/source_code/hku-icb-class/frontend run build)", "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(/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 # SMTP Email
CH_SMTP_HOST=gz-smtp.qcloudmail.com CH_SMTP_HOST=gz-smtp.qcloudmail.com
CH_SMTP_PORT=465 CH_SMTP_PORT=465
CH_SMTP_USER=noreply CH_SMTP_USER=noreply@hkuicb.info
CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69 CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69
CH_SMTP_FROM_EMAIL=noreply@hkuicb.info CH_SMTP_FROM_EMAIL=noreply@hkuicb.info
CH_SMTP_FROM_NAME=HKU ICB Class Hub 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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_role from app.core.deps import require_role
@ -76,11 +76,11 @@ async def get_resources(
@router.post("/", response_model=ResourceOut) @router.post("/", response_model=ResourceOut)
async def upload_new_resource( async def upload_new_resource(
title: str,
category: str,
file: UploadFile = File(...), file: UploadFile = File(...),
description: str | None = None, title: str = Form(...),
class_id: int | None = None, category: str = Form(...),
description: str | None = Form(None),
class_id: int | None = Form(None),
user: User = Depends(require_role("super_admin", "class_admin")), user: User = Depends(require_role("super_admin", "class_admin")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):

View File

@ -21,10 +21,15 @@ async def create_announcement(
await db.commit() await db.commit()
await db.refresh(announcement) 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( await create_notifications_for_class(
db, class_id, "announcement", f"新公告: {data.title}", 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 return announcement

View File

@ -57,3 +57,39 @@ async def send_approval_notification(student_email: str, approved: bool):
await send_email( await send_email(
student_email, f"HKU ICB: Registration {status_text.capitalize()}", html 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 import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Notification, User 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( async def create_notification(
@ -33,19 +39,21 @@ async def create_notifications_for_class(
content: str | None = None, content: str | None = None,
related_id: int | None = None, related_id: int | None = None,
exclude_user_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( result = await db.execute(
select(User.id).where( select(User.id, User.email).where(
User.class_id == class_id, User.class_id == class_id,
User.status == "approved", User.status == "approved",
) )
) )
user_ids = [row[0] for row in result.all()] rows = result.all()
for uid in user_ids: emails: list[str] = []
if exclude_user_id and uid == exclude_user_id: for uid, email in rows:
continue
notification = Notification( notification = Notification(
user_id=uid, user_id=uid,
type=type, type=type,
@ -54,9 +62,26 @@ async def create_notifications_for_class(
related_id=related_id, related_id=related_id,
) )
db.add(notification) db.add(notification)
emails.append(email)
await db.commit() 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( async def list_notifications(
db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20 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.commit()
await db.refresh(item) 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( await create_notifications_for_class(
db, class_id, "schedule", f"新排期: {data.title}", db, class_id, "schedule", f"新排期: {data.title}",
content=f"{time_str}{location_info}",
related_id=item.id, 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 return item

View File

@ -7,6 +7,7 @@ from sqlalchemy.orm import selectinload
from app.db.models import Timeline, User from app.db.models import Timeline, User
from app.schemas.timeline import TimelineCreate, TimelineUpdate from app.schemas.timeline import TimelineCreate, TimelineUpdate
from app.services.notification_service import create_notifications_for_class
async def create_timeline( async def create_timeline(
@ -21,6 +22,18 @@ async def create_timeline(
db.add(post) db.add(post)
await db.commit() await db.commit()
await db.refresh(post) 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 return post