Communication & Notification Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Communication & Notification 领域负责整个 Speaker Platform 中所有与通信、通知相关的功能。其核心职责包括:
- 邮件通信 (Email Communication): 为 planner 提供向与会者 (attendees) 发送邮件的能力,支持草稿、发送、转发、预览、模板变量替换等完整邮件工作流
- 邮件邀请 (Email Invitation): 基于 service type 配置的邀请模板,向与会者发送标准化的活动邀请邮件,支持邀请追踪
- 定时邮件 (Scheduled Email): 支持按条件定时发送邮件,包含"立即发送"和"定时任务"两种模式
- 调查问卷邮件 (Survey Email): 向与会者发送活动后的调查问卷链接
- 提醒系统 (Reminder System): 多种提醒机制,包括注册提醒、待审批提醒、邀请未注册提醒
- 告警系统 (Alert System): 双重告警体系 -- Speaker 端的 Alert (任务提醒) 和 Planner/Sales 端的 MeetingAlert + CustomAlert + BudgetAlert (业务告警)
- 退订管理 (Unsubscribe Management): 管理邮件退订名单,在发送邮件时自动过滤退订用户
- SendGrid 集成: 通过 SendGrid API 同步邮件活动数据(打开率、点击率等)
- 邮件沙盒 (Email Sandbox): 测试环境下将所有邮件重定向到指定测试邮箱
- iCalendar 生成: 为活动邀请生成日历附件
1.2 涉及的后端模块和包
| 模块 | 路径 | 文件数 | 职责 |
|---|---|---|---|
modules/v1/mail/ | 21 | 邮件发送核心、退订管理、沙盒模式、调查邮件 | |
| communication | modules/v1/communication/ | 10 | 邮件通信 CRUD、邀请发送、定时邮件、模板变量替换 |
| sendgrid | modules/v1/sendgrid/ | 3 | SendGrid API 集成、邮件活动数据同步 |
| alert | modules/v1/alert/ | 5 | 自定义告警 (CustomAlert) CRUD |
| reminder | modules/v1/reminder/ | 3 | Speaker 端提醒聚合 |
| invitationtemplate | modules/v1/invitationtemplate/ | 4 | 邀请模板管理 |
| 定时任务 | common/task/ | 4个相关文件 | MailTask, MeetingTask, SendGridTask, InvitedAttendeeReminderTask |
| 实体层 | common/persistence/entity/ | 15+ 个相关实体 | 数据模型定义 |
2. Data Model Analysis
2.1 Entity Overview Table
| 实体类 | 表名 | 主要字段 | 文件位置 | 用途 |
|---|---|---|---|---|
Mail | t_mail | mailId, meetingId, mailType, mailFrom, mailSubject, emailInfo, mailTo, mailCc, mailBcc, attachment, mailAlias, headerImg, footerImg, backgroundImage, productId, enableInvitationTrack, config, status, modifiedDate, sentDate, priority | entity/Mail.java:22 | 邮件记录 (双重职责: 通信邮件 + 邀请模板) |
MailServiceType | t_mail_service_type | id, mailId, serviceTypeId | entity/MailServiceType.java:21 | 邮件模板与 service type 的关联 |
ScheduledEmail | t_scheduled_email | id, meetingId, subject, content, fromAlias, attachment, config(jsonb), status, deleted, createdAt/By, updatedAt/By | entity/ScheduledEmail.java:22 | 定时邮件模板定义 |
ScheduledEmailInstance | t_scheduled_email_instance | id, meetingId, emailId, type(0:scheduled/1:sendNow), subject, content, fromAlias, attachment, config(jsonb), createdAt/By | entity/ScheduledEmailInstance.java:22 | 定时邮件发送记录 |
UnsubscribedEmail | t_unsubscribed_email | id, productId, firstName, lastName, email, npi, status(0:unsubscribed/1:resubscribed), createdAt/By, updatedAt/By | entity/UnsubscribedEmail.java:22 | 邮件退订记录 |
EmailSandbox | t_email_sandbox | id, enabled, emails(jsonb) | entity/EmailSandbox.java:21 | 沙盒模式配置 (全局唯一记录 id=1) |
SendGridEmailActivity | sendgrid_email_activity | id, msgId, fromEmail, toEmail, subject, status, opensCount, clicksCount, lastEventTime, createdAt, updatedAt | entity/SendGridEmailActivity.java:23 | SendGrid 邮件活动追踪数据 |
InvitationHistory | t_invitation_history | id, mailId, attendeeId, meetingId, readTime, accessTime, createdAt | entity/InvitationHistory.java:22 | 邀请邮件追踪记录 |
Alert | t_alert | id, name, type(0:training/1:presentation/2:expense/3:survey), objectId, speakerId, createdAt, companyId | entity/Alert.java:8 | Speaker 端任务告警 |
CustomAlert | t_custom_alert | id, productId, linkText, description, fileId, status(0:inactive/1:active), createdAt, type(0:file/1:url), url | entity/CustomAlert.java:8 | 自定义推送告警 (由 planner 管理) |
CustomAlertViewHistory | t_custom_alert_view_history | id, customAlertId, portalUserId, createdAt, userId | entity/CustomAlertViewHistory.java:22 | 自定义告警查看记录 |
MeetingAlert | t_meeting_alert | id, meetingRequestId, name, createdAt | entity/MeetingAlert.java:8 | 会议相关告警 (取消、签到关闭等) |
BudgetAlert | t_budget_alert | id, fiscalPeriod, amount, regionId, createdAt | entity/BudgetAlert.java:23 | 预算告警 |
BudgetAlertViewHistory | t_budget_alert_view_history | id, budgetAlertId, portalUserId, createdAt, userId | entity/BudgetAlertViewHistory.java:22 | 预算告警查看记录 |
ForceTargetInvitation | t_force_target_invitation | meetingRequestId, targetId | entity/ForceTargetInvitation.java:21 | 强制 target 邀请关联 |
ForceTargetInvitationConfig | t_force_target_invitation_config | teamId, minimumCount, roles, status, createdAt/By, updatedAt/By | entity/ForceTargetInvitationConfig.java:22 | 强制 target 邀请配置 |
2.2 Table Relationships (ER Diagram - ASCII)
┌────────────────────────┐
│ MeetingRequest │
│ (meeting_request_id) │
└──────┬────┬─────────────┘
│ │
┌─────────────────┘ └──────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ MeetingAlert │ │ Meeting │
│ meetingRequestId ──►│ │ (meetingId) │
│ name │ └──────┬──────────────┘
└─────────────────────┘ │
│ 1:N
┌──────────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ Mail │ │ ScheduledEmail │ │ Attendee │
│ mailId │ │ id, meetingId │ │ attendeeId │
│ meetingId │ │ subject, content │ │ meetingId │
│ mailType │ │ config (jsonb) │ │ email │
│ mailTo │ │ status, deleted │ └────┬──────────────┘
│ productId │ └────────┬─────────┘ │
└──┬──────────────┘ │ 1:N │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ScheduledEmailInstance │ │
│ │ emailId ──► ScheduledEmail.id │
│ │ type (0:scheduled/1:now) │
│ └──────────────────────┘ │
│ │
│ 1:N │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ MailServiceType │ │ InvitationHistory │
│ mailId ──► Mail │ │ mailId ──► Mail │
│ serviceTypeId │ │ attendeeId │
└─────────────────────┘ │ meetingId │
│ readTime, accessTime│
└──────────────────────┘
┌──────────────────┐ ┌──────────────────────────┐
│ Product │◄───┐ │ UnsubscribedEmail │
│ (productId) │ └──│ productId │
└──────┬───────────┘ │ email, npi │
│ │ status (0:unsub/1:resub) │
│ 1:N └──────────────────────────┘
▼
┌──────────────────┐ ┌──────────────────────────┐
│ CustomAlert │ │ SendGridEmailActivity │
│ productId │ │ msgId (unique) │
│ linkText, desc │ │ fromEmail, toEmail │
│ type (file/url) │ │ status, opens, clicks │
│ fileId, url │ │ lastEventTime │
└──────┬───────────┘ └──────────────────────────┘
│ 1:N
▼
┌───────────────────────┐
│CustomAlertViewHistory │
│ customAlertId │
│ portalUserId/userId │
└───────────────────────┘
┌──────────────────┐ ┌──────────────────────────┐
│ Alert │ │ EmailSandbox │
│ speakerId │ │ id=1 (singleton) │
│ type (0-6) │ │ enabled (boolean) │
│ objectId │ │ emails (jsonb array) │
│ companyId │ └──────────────────────────┘
└──────────────────┘
┌──────────────────────┐ ┌──────────────────────────┐
│ BudgetAlert │ │ BudgetAlertViewHistory │
│ fiscalPeriod │ │ budgetAlertId │
│ amount, regionId │ │ portalUserId/userId │
└──────────────────────┘ └──────────────────────────┘2.3 Data Model Issues
Issue 1: Mail 表的职责混乱 (Critical)
t_mail表同时承担两个完全不同的职责:- 当
meetingId != null且mailType= send/drafts/delete 时,作为 通信邮件记录 (由CommunicationService管理) - 当
productId != null且与MailServiceType关联时,作为 邀请模板 (由InvitationTemplateService管理)
- 当
- 这导致同一张表中的记录有完全不同的生命周期和业务语义
- 文件引用:
entity/Mail.java:22,CommunicationService.java:76,InvitationTemplateService.java:26
Issue 2: Alert 体系碎片化 (Critical)
- 系统中存在 5 种不同的告警实体,分散在不同模块中:
t_alert-- Speaker 端任务告警 (training/presentation/expense/survey/video)t_custom_alert-- Planner 管理的自定义告警 (链接/文件推送)t_meeting_alert-- 会议状态告警 (取消/签到关闭)t_budget_alert-- 预算告警- Salesview 的 alerts 通过
GET /api/v1/programs/{productId}/alerts从另一个接口获取 (含 7+ 种 alertType)
- 没有统一的告警模型,每种告警的结构、生命周期、查看历史记录方式都不同
Issue 3: config 字段使用 Object 类型 (Design Defect)
Mail.config,ScheduledEmail.config,ScheduledEmailInstance.config,EmailSandbox.emails都使用Object类型 +JdbcType.OTHER(对应 PostgreSQL jsonb)- 没有 Java 端的类型定义,完全依赖运行时 cast,例如
CommunicationService.java:904:javaMap<String, Object> config = (Map<String, Object>) email.getConfig(); - 这导致编译时无法检查类型错误,且 API 文档无法反映实际数据结构
Issue 4: mailTo 字段使用分号分隔的 attendeeId 列表 (Design Defect)
Mail.mailTo存储的是分号分隔的 attendeeId 字符串,如"att1;att2;att3"- 参见
CommunicationService.java:358:Splitter.on(";").omitEmptyStrings().splitToList(mrd.getMailTo()) - 应使用关联表或 JSON 数组
Issue 5: 缺少外键约束和级联删除
MailServiceType.mailId引用Mail.mailId,但InvitationTemplateService.deleteInvitationTemplate()删除 Mail 时不会自动删除关联的MailServiceType记录ScheduledEmailInstance.emailId引用ScheduledEmail.id,但deleteScheduledEmail()只做逻辑删除 (设deleted=true),不影响已有 instance
Issue 6: EmailSandbox 使用硬编码 id=1 (Design Defect)
EmailSandboxService.java:32:emailSandboxMapper.selectByPrimaryKey(1)- 整个系统只有一条 sandbox 配置记录,这是合理的 singleton 模式,但硬编码的方式不够优雅
3. Business Flow Analysis
3.1 Core Business Flows
3.1.1 Email Sending Flow (Direct + SendGrid)
┌─────────────┐
│ Frontend │ POST /api/v1/communications
│ (Planner) │──────────────────────────────────────────►│
└─────────────┘ │
▼
┌─────────────────────┐
│ CommunicationController│
│ sendMail(mrd) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ CommunicationService │
│ │
│ 1. createMail/ │
│ updateMail │
│ 2. sendMailToAttendees│
│ 3. updateAttendeeStatus│
└──────────┬──────────┘
│
┌──────────────────────────────┤
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Check Unsubscribed│ │ replaceTemplateVariable│
│ getUnsubscribedList│ │ 40+ merge fields: │
│ (filter out) │ │ %%FIRST_NAME%% │
└──────────────────┘ │ %%MEETING_NAME%% │
│ %%SPEAKER_FIRST_NAME%%│
│ │ ##attendeeId## │
▼ └───────────┬──────────┘
┌──────────────────┐ │
│ For each attendee:│◄────────────────────┘
│ Email email = new│
│ email.setTo(...)│
│ email.setSubject│
└──────────┬───────┘
│
▼
┌──────────────────────────────────┐
│ MailService.sendMailByTemplate() │
│ @Async │
│ │
│ 1. Check EmailSandbox │
│ isSandboxModeActive()? │
│ ├─ YES: redirect to sandbox │
│ │ addresses, prefix subject │
│ │ with [SANDBOX], add info │
│ │ box to content │
│ └─ NO: use original recipients│
│ │
│ 2. MimeMessagePreparator │
│ - set To/Cc/Bcc │
│ - set From (default or alias) │
│ - set ReplyTo │
│ - add attachments (files) │
│ - add inline images (CID) │
│ - add iCalendar (.ics) │
│ - process Thymeleaf template │
│ │
│ 3. JavaMailSender.send() │
│ (SMTP via Spring Mail) │
└──────────────────────────────────┘关键文件引用:
CommunicationService.java:247-266--sendMail()入口CommunicationService.java:316-410--sendMailToAttendees()核心逻辑CommunicationService.java:421-546--replaceTemplateVariable()模板变量替换 (40+ 变量)MailService.java:109-298--sendMailByTemplate()实际邮件发送 (含 sandbox 逻辑)
3.1.2 Invitation Template System
┌──────────────────────────────────────────────────────────────┐
│ Invitation Template Flow │
│ │
│ Admin creates template: │
│ InvitationTemplateController │
│ POST /api/v1/invitation-templates │
│ → creates Mail record (with productId, no meetingId) │
│ │
│ Admin assigns template to service types: │
│ PUT /api/v1/invitation-templates/{id}/templates │
│ → creates MailServiceType records │
│ (links mailId to serviceTypeId) │
│ │
│ Planner sends invitation to attendee: │
│ CommunicationService.sendEmailInvitation(attendeeId) │
│ 1. Look up attendee → get meetingId │
│ 2. Look up meetingRequest → get serviceType │
│ 3. Look up MailServiceType → find mailId for serviceType │
│ 4. Load Mail template by mailId │
│ 5. Replace template variables │
│ 6. If enableInvitationTrack: │
│ - Create InvitationHistory record │
│ - Add tracking pixel to content │
│ - Add invitationId to registration link │
│ 7. Send via MailService │
└──────────────────────────────────────────────────────────────┘关键文件引用:
InvitationTemplateService.java:53-56-- 创建邀请模板InvitationTemplateService.java:77-96-- 关联 service typeCommunicationService.java:599-625--sendEmailInvitation()发送流程CommunicationService.java:627-717--getEmailInvitation()构建邀请邮件
3.1.3 Dual Alert System
系统中存在完全分离的告警体系,面向不同用户角色:
┌──────────────────────────────────────────────────────────────────┐
│ SPEAKER-SIDE ALERTS (t_alert) │
│ │
│ AlertType enum (speaker/constant/AlertType.java): │
│ 0 = TRAINING_MODULE -- 未完成的培训模块 │
│ 1 = PRESENTATION -- 演示文稿相关 │
│ 2 = EXPENSE -- 费用报告相关 │
│ 3 = SURVEY -- 调查问卷 │
│ 5 = SPEAKER_SURVEY -- Speaker 专属调查 │
│ 6 = VIDEO -- 新视频可用 │
│ │
│ Consumed by: │
│ SpeakerReminderService.getReminders(speakerId) │
│ → Aggregates: hasUntrainedModules + hasUnsignedContracts │
│ + hasNewVideos │
│ Speakerview AlertList component │
│ → Displays as actionable table (click to take action) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ PLANNER/SALES-SIDE ALERTS (Multiple tables) │
│ │
│ 1. MeetingAlert (t_meeting_alert) │
│ - Created by ProgramAlertService │
│ - Types: Program Canceled, Attendee List Closed │
│ - Consumed by: GET /api/v1/meetings/alerts │
│ - Can be bulk deleted: DELETE /api/v1/meetings/alerts │
│ │
│ 2. CustomAlert (t_custom_alert) │
│ - CRUD managed by CustomAlertController │
│ - Push alerts with file or URL links per product │
│ - Has view history tracking (CustomAlertViewHistory) │
│ - Consumed by Salesview through program alerts endpoint │
│ │
│ 3. BudgetAlert (t_budget_alert) │
│ - Created by BudgetAllocationService │
│ - Has view history tracking (BudgetAlertViewHistory) │
│ - Consumed by Salesview through program alerts endpoint │
│ │
│ 4. Salesview Alert System (unified endpoint) │
│ GET /api/v1/programs/{productId}/alerts │
│ Returns combined alerts with alertType: │
│ 0 = Pending Approval │
│ 1 = Custom Alert (file/url) │
│ 2 = Post-program evaluation survey │
│ 3 = Generic notification │
│ 4 = Pending Approval variant │
│ 5 = Budget Alert │
│ 6 = Approver Comment │
│ 7 = Completed program │
└──────────────────────────────────────────────────────────────────┘关键文件引用:
Alert.java:8-- Speaker 端告警实体AlertType.java:3-- 告警类型枚举 (注意: type=4 缺失,可能为已删除类型)SpeakerReminderService.java:31-63-- 聚合 Speaker 提醒CustomAlertController.java:29-- 自定义告警 CRUDProgramAlertService.java:11-- Meeting 告警创建pharmagin-salesview/src/components/Header/alertModal.js:40-101-- Salesview 告警渲染 (8种类型)
3.1.4 Scheduled Email System
┌────────────────────────────────────────────────────────────────────┐
│ Scheduled Email Flow │
│ │
│ 1. CREATE: POST /api/v1/communications/scheduled-emails │
│ → CommunicationService.createScheduledEmail() │
│ → Save to t_scheduled_email (status=active, deleted=false) │
│ → If type = SEND_NOW(1): immediately call sendScheduledEmail() │
│ │
│ 2. SCHEDULED EXECUTION: MailTask.sendScheduledEmails() │
│ @Scheduled(cron = "0 0 8 * * ?", zone = "America/New_York") │
│ → Runs daily at 8:00 AM ET │
│ → Queries all active, non-deleted ScheduledEmail records │
│ → For each email: │
│ - Read config JSON: {scheduleEmail, sendType, ...} │
│ - sendType=0: X days BEFORE meeting date │
│ - sendType=1: X days AFTER meeting date │
│ - sendType=2: Every X days within date range [from, to] │
│ - If date matches → sendScheduledEmail() │
│ │
│ 3. ACTUAL SENDING: CommunicationService.sendScheduledEmail() │
│ → Get attendees by config criteria: │
│ - attendeeTypeIds │
│ - registrationStatus │
│ - checkInStatus │
│ → Call sendMailToAttendees() (same as manual email) │
│ → Create ScheduledEmailInstance record (execution log) │
│ │
│ 4. LIFECYCLE: │
│ - Activate/Deactivate: PUT .../scheduled-emails/{id}/activate │
│ - Delete (logical): DELETE .../scheduled-emails/{id} │
│ - View instances: GET .../scheduled-emails/instances │
└────────────────────────────────────────────────────────────────────┘关键文件引用:
MailTask.java:37-87-- 定时任务核心逻辑CommunicationService.java:849-923-- 定时邮件创建和发送CommunicationService.java:926-963-- 获取匹配的参会者
3.1.5 Reminder System
┌────────────────────────────────────────────────────────────────────┐
│ Reminder Subsystems │
│ │
│ 1. REGISTRANT REMINDER │
│ MailTask.sendRegistrantReminder() │
│ @Scheduled(cron = "0 0 7 * * ?", zone="America/New_York") │
│ → Daily 7AM ET: re-send invitation to attendees with status │
│ "invited" (not yet registered) │
│ → Uses AttendeeService.sendRegistrantReminder(attendeeId) │
│ │
│ 2. INVITED ATTENDEE REMINDER TO PLANNERS │
│ InvitedAttendeeReminderTask.sendInvitedAttendeeReminderToPlanners│
│ @Scheduled(cron = "0 0 8 ? * SUN", zone = "America/New_York") │
│ → Every Sunday 8AM ET │
│ → Sends summary of invited-but-not-registered attendees │
│ to configured planner email addresses │
│ → Template: invited-attendees-reminder.html │
│ │
│ 3. INVITED ATTENDEE REMINDER TO ATTENDEES │
│ InvitedAttendeeReminderTask.sendInvitedAttendeeReminderToAttendees│
│ @Scheduled(cron = "0 0 8 ? * TUE", zone = "America/New_York") │
│ → Every Tuesday 8AM ET │
│ → Re-sends invitation email to each invited attendee │
│ → Uses CommunicationService.sendEmailInvitation() │
│ │
│ 4. PENDING APPROVAL REMINDER │
│ MeetingTask.sendPendingApprovalReminderMail() │
│ @Scheduled(cron = "0 0 2 * * ?") │
│ → Daily 2AM: sends reminder to district managers if program │
│ has been pending approval for > 2 days │
│ → Hardcoded productId=24 │
│ → Template: pending-approval-reminder.html │
│ │
│ 5. SPEAKER REMINDER (Query-based, no push) │
│ SpeakerReminderService.getReminders(speakerId) │
│ → Returns boolean flags: hasUntrainedModules, │
│ hasUnsignedContracts, hasNewVideos │
│ → Consumed by speaker portal on login/navigation │
└────────────────────────────────────────────────────────────────────┘关键文件引用:
MailTask.java:89-101-- 注册提醒InvitedAttendeeReminderTask.java:33-87-- 邀请提醒 (周日给 planner, 周二给 attendee)MeetingTask.java:50-82-- 待审批提醒 (硬编码 productId=24)SpeakerReminderService.java:31-63-- Speaker 提醒查询
3.1.6 Unsubscribe Handling
┌────────────────────────────────────────────────────────────────────┐
│ Unsubscribe Flow │
│ │
│ 1. ADMIN UPLOAD (Bulk): │
│ POST /api/v1/unsubscribed-emails/upload │
│ → Excel file with columns: productName, firstName, lastName, │
│ email, npi │
│ → Maps productName to productId │
│ → Inserts with status=0 (Unsubscribed) │
│ → Silently swallows DuplicateKeyException │
│ │
│ 2. ATTENDEE SELF-UNSUBSCRIBE: │
│ POST /api/v1/public/attendees/{attendeeId}/unsubscribed-email │
│ → Looks up attendee → gets productId from meetingRequest │
│ → Creates UnsubscribedEmail record │
│ │
│ 3. ADMIN MANAGE: │
│ PUT /api/v1/unsubscribed-emails/status │
│ PUT /api/v1/unsubscribed-emails/{id} │
│ GET /api/v1/unsubscribed-emails/download │
│ │
│ 4. ENFORCEMENT (during email sending): │
│ CommunicationService.sendMailToAttendees(): │
│ unsubscribedList = getUnsubscribedList(productId) │
│ for each attendee: │
│ if unsubscribedList.contains(attendee.email.toLowerCase()) │
│ → SKIP, add to matchedUnsubscribedList │
│ return matchedUnsubscribedList (shown to planner) │
│ │
│ 5. RESUBSCRIBE: │
│ PUT .../unsubscribed-emails/{id}/resubscribe │
│ → Sets status to 1 (Resubscribed) │
└────────────────────────────────────────────────────────────────────┘关键文件引用:
UnsubscribedEmailService.java:82-119-- 批量上传UnsubscribedEmailService.java:228-248-- Attendee 自助退订CommunicationService.java:370-376-- 发送时过滤退订用户
3.1.7 SendGrid Integration
┌────────────────────────────────────────────────────────────────────┐
│ SendGrid Integration Flow │
│ │
│ NOTE: SendGrid is used for EMAIL ACTIVITY TRACKING, NOT for │
│ email sending. Emails are sent via JavaMailSender (SMTP). │
│ │
│ SendGridTask.performDailySync() │
│ @Scheduled(cron = "0 0 2 * * ?", zone = "America/New_York") │
│ → Daily 2AM ET │
│ │
│ 1. Check if SendGrid is configured (apiKeyId exists) │
│ 2. Get next start time (last synced event time or 30 days ago) │
│ 3. Fetch from SendGrid API: /messages?limit=1000&query=... │
│ 4. If response >= 1000 records: │
│ → Adaptive time-slicing strategy: │
│ - Split date range in half (binary search) │
│ - If single day ≥ 1000: switch to intra-day time slicing │
│ - Minimum slice: 5 minutes │
│ 5. Parse response → SendGridEmailActivity entities │
│ 6. Batch upsert to sendgrid_email_activity table │
│ │
│ Data fields synced: msgId, fromEmail, toEmail, subject, │
│ status, opensCount, clicksCount, lastEventTime │
└────────────────────────────────────────────────────────────────────┘关键文件引用:
SendGridTask.java:17-35-- 定时同步任务SendGridService.java:59-77-- 增量同步逻辑SendGridService.java:108-152-- 自适应时间分片获取SendGridService.java:170-227-- 递归时间分片 (日级)SendGridService.java:228-295-- 递归时间分片 (时/分级)
3.2 Validation Rules
| 规则 | 位置 | 描述 |
|---|---|---|
| 邮件必填项 | MailRequestDTO.java:13-36 | meetingId (NotNull), emailInfo (NotBlank), mailTo (NotBlank) |
| SendGrid 实体校验 | SendGridEmailActivity.java:31-57 | msgId, fromEmail, toEmail (NotBlank+Email), status (NotBlank), lastEventTime (NotNull) |
| 邀请发送前置条件 | CommunicationService.java:641-651 | 必须有对应 serviceType 的邀请模板、注册站点必须为 Active 状态 |
| 退订上传校验 | UnsubscribedEmailService.java:93-97 | Product name 必须能匹配到已有 product |
| 自定义告警表单 | PushAlert/index.js:339-438 | productId, linkText, description 必填; type=url 时 url 必填; type=file 时 fileName 必填 |
| 邮件收件人校验 | MailService.java:157-160 | finalToAddressesList 为空时直接 return,不发送 |
3.3 Business Logic Issues
Issue 1: 硬编码的 DST 时间表 (Critical Bug Risk)
MailService.java:639-658:getDSTSavings()方法用硬编码的日期范围判断夏令时- 只覆盖到 2030 年,2031 年起将导致 iCalendar 时间偏移 1 小时
- 应使用 Java TimeZone API 的
inDaylightTime()方法
Issue 2: 待审批提醒硬编码 productId (Critical)
MeetingTask.java:54:params.put("productId", 24)-- 只对 productId=24 的客户发送待审批提醒- 这意味着其他客户的程序不会收到待审批提醒
Issue 3: 退订检查仅在通信模块生效 (Design Defect)
- 退订过滤只在
CommunicationService.sendMailToAttendees()中实现 MailService的sendMail()/sendCancellationMail()等方法不检查退订名单- 确认邮件、取消邮件等可能发送给已退订的用户
Issue 4: 模板变量替换使用已废弃的 API (Technical Debt)
CommunicationService.java:527: 使用org.apache.commons.lang3.text.StrSubstitutor(已在 commons-lang3 3.6 中标记为 @Deprecated)- 应迁移到
org.apache.commons.text.StringSubstitutor
Issue 5: DuplicateKeyException 被静默吞没 (Design Defect)
UnsubscribedEmailService.java:117-118: 批量上传时DuplicateKeyException被空 catch 块吞没- 用户无法知道哪些记录因重复而未导入
Issue 6: Sandbox 模式下每次发邮件都查询数据库 (Performance)
MailService.sendMailByTemplate()每次调用时都通过emailSandboxService.isSandboxModeActive()查询数据库- 在批量发送时 (如 sendMailToAttendees 循环中),会产生大量重复的 sandbox 配置查询
isSandboxModeActive()内部调用getEmailSandboxSettings()再调用parseRedirectAddressesInternal(),每次查询两次数据库
Issue 7: 邮件发送错误处理不一致
MailService.sendMailByTemplate()捕获MailException并记录日志后继续 (静默失败)sendCancellationMail()捕获Exception并记录日志后继续- 调用方无法知道邮件是否发送成功 (因为
@Async) - 没有重试机制
Issue 8: sendCancellationMail 重用 MimeMessage 对象
MailService.java:376-389: 在循环中重用同一个MimeMessage和MimeMessageHelper发送给多个 planner- 先设置
helper.setTo(plannerMail)再发送,可能导致之前设置的 Text 内容残留
4. API Inventory
4.1 REST Endpoints Table
Mail Module (v1/mails)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| POST | /v1/mails/confirmation | MailController:78 | sendConfirmation() | 手动发送确认邮件 |
| POST | /v1/mails/survey | MailController:84 | sendSurvey() | 发送会议调查邮件 |
| POST | /v1/mails/program/survey | MailController:90 | sendProgramSurvey() | 发送项目调查邮件 |
| POST | /v1/mails/compliance | MailController:179 | sendComplianceNotification() | 发送合规通知邮件 |
Communication Module (v1/communications)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/communications | CommunicationController:42 | list() | 获取邮件列表 (按 meetingId + mailType) |
| GET | /v1/communications/{mailId} | CommunicationController:52 | mailDetail() | 获取邮件详情 |
| PUT | /v1/communications/{mailId}/use-invitation-template | CommunicationController:57 | useInvitationTemplate() | 将邮件关联到邀请模板 |
| PUT | /v1/communications | CommunicationController:63 | saveDraft() | 保存草稿 |
| POST | /v1/communications | CommunicationController:69 | sendMail() | 发送邮件 |
| DELETE | /v1/communications | CommunicationController:75 | deleteMail() | 删除邮件 (逻辑删除) |
| POST | /v1/communications/preview | CommunicationController:81 | sendPreviewMail() | 发送预览邮件 |
| POST | /v1/communications/forward | CommunicationController:87 | forwardMail() | 转发邮件 |
| POST | /v1/communications/upload | CommunicationController:93 | uploadAttach() | 上传邮件附件 |
| POST | /v1/communications/upload-image | CommunicationController:99 | uploadImage() | 上传邮件内嵌图片 |
| GET | /v1/communications/reply-to-email-address | CommunicationController:104 | getReplyToEmailAddress() | 获取回复邮箱地址 |
| GET | /v1/communications/scheduled-emails | CommunicationController:109 | listScheduledEmails() | 获取定时邮件列表 |
| POST | /v1/communications/scheduled-emails | CommunicationController:114 | createScheduledEmail() | 创建定时邮件 |
| PUT | /v1/communications/scheduled-emails/{id} | CommunicationController:119 | updateScheduledEmail() | 更新定时邮件 |
| DELETE | /v1/communications/scheduled-emails/{id} | CommunicationController:124 | deleteScheduledEmail() | 删除定时邮件 |
| PUT | /v1/communications/scheduled-emails/{id}/activate | CommunicationController:129 | activateScheduledEmail() | 激活定时邮件 |
| PUT | /v1/communications/scheduled-emails/{id}/deactivate | CommunicationController:133 | deactivateScheduledEmail() | 停用定时邮件 |
| GET | /v1/communications/scheduled-emails/instances | CommunicationController:139 | listScheduledEmailInstances() | 获取定时邮件执行记录 |
Unsubscribed Email Module (v1/unsubscribed-emails)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/unsubscribed-emails | UnsubscribedEmailController:28 | listUnsubscribedEmails() | 获取退订列表 |
| POST | /v1/unsubscribed-emails/upload | UnsubscribedEmailController:33 | upload() | 批量上传退订名单 |
| GET | /v1/unsubscribed-emails/download | UnsubscribedEmailController:38 | download() | 下载退订列表 |
| PUT | /v1/unsubscribed-emails/status | UnsubscribedEmailController:43 | updateStatus() | 批量更新退订状态 |
| PUT | /v1/unsubscribed-emails/{id} | UnsubscribedEmailController:48 | update() | 更新退订记录 |
Attendee Unsubscribe (Public, v1/public/attendees/{attendeeId})
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/public/attendees/{attendeeId}/unsubscribed-email | AttendeeUnsubscribedEmailController:21 | getUnsubscribedEmailByAttendeeId() | 查询退订状态 |
| POST | /v1/public/attendees/{attendeeId}/unsubscribed-email | AttendeeUnsubscribedEmailController:26 | addAttendeeEmailToUnsubscribedList() | 添加到退订列表 |
| PUT | /v1/public/attendees/{attendeeId}/unsubscribed-emails/{id}/unsubscribe | AttendeeUnsubscribedEmailController:32 | unsubscribe() | 退订 |
| PUT | /v1/public/attendees/{attendeeId}/unsubscribed-emails/{id}/resubscribe | AttendeeUnsubscribedEmailController:38 | resubscribe() | 重新订阅 |
Email Sandbox (v1/email-sandbox)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/email-sandbox/settings | EmailSandboxController:20 | getEmailSandboxSettings() | 获取沙盒设置 |
| POST | /v1/email-sandbox/settings | EmailSandboxController:25 | updateEmailSandboxSettings() | 更新沙盒设置 |
Custom Alert (v1/customAlerts)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/customAlerts | CustomAlertController:36 | listCustomAlerts() | 获取自定义告警列表 (分页) |
| POST | /v1/customAlerts | CustomAlertController:42 | save() | 创建自定义告警 |
| PUT | /v1/customAlerts/{id} | CustomAlertController:48 | update() | 更新自定义告警 |
| PUT | /v1/customAlerts/{id}/status | CustomAlertController:54 | updateStatus() | 更新告警状态 |
| GET | /v1/customAlerts/{id} | CustomAlertController:61 | getDetailById() | 获取告警详情 |
| DELETE | /v1/customAlerts/{id} | CustomAlertController:67 | delete() | 删除自定义告警 (物理删除) |
Speaker Reminder (v1/speakers/{speakerId}/reminders)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/speakers/{speakerId}/reminders | SpeakerReminderController:20 | getReminders() | 获取 Speaker 提醒状态 |
Invitation Templates (v1/invitation-templates)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/invitation-templates | InvitationTemplateController:26 | listInvitationTemplates() | 获取邀请模板列表 (分页) |
| POST | /v1/invitation-templates | InvitationTemplateController:31 | createInvitationTemplate() | 创建邀请模板 |
| PUT | /v1/invitation-templates/{id} | InvitationTemplateController:36 | updateInvitationTemplate() | 更新邀请模板 |
| DELETE | /v1/invitation-templates/{id} | InvitationTemplateController:41 | deleteInvitationTemplate() | 删除邀请模板 (物理删除) |
| PUT | /v1/invitation-templates/{id}/templates | InvitationTemplateController:46 | setTemplate() | 关联 service types |
Meeting Alerts (within MeetingController)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/meetings/alerts | MeetingController:136 | listMeetingAlerts() | 获取当前用户的会议告警列表 |
| DELETE | /v1/meetings/alerts | MeetingController:153 | deletesMeetingAlerts() | 删除所有会议告警 |
SendGrid (empty controller, backend-only sync)
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| (无 REST endpoint) | - | SendGridController:11 | - | 控制器为空,SendGrid 数据同步仅通过定时任务执行 |
Cron Job Triggers
| Method | Path | Controller | 方法 | 描述 |
|---|---|---|---|---|
| GET | /v1/cronjobs/send-invited-attendee-reminder-to-planners | CronJobsController:23 | sendInvitedAttendeeReminder() | 手动触发邀请提醒 |
4.2 API Design Issues
Issue 1: Communication 的 DELETE 方法使用 RequestBody (Bad Practice)
CommunicationController.java:75:@DeleteMapping+@RequestBody List<Integer> ids- HTTP DELETE 规范上不推荐使用 request body,部分 HTTP 客户端/代理可能丢弃 DELETE body
- 应改为 POST 或将 ids 放入 query parameters
Issue 2: Preview 端点的 Swagger 注解错误
CommunicationController.java:79:@ApiOperation(value = "Send Preview Mail", httpMethod = "DELETE")但实际是@PostMapping- Swagger UI 中显示的 HTTP 方法与实际不符
Issue 3: 批量删除 MeetingAlert 过于危险
MeetingController.java:153-156:DELETE /v1/meetings/alerts会删除 所有 MeetingAlert 记录 (无 where 条件)- 应限定只删除当前用户的告警
Issue 4: SendGridController 是空控制器
SendGridController.java:11: 只有类定义,没有任何端点- SendGrid 数据只通过定时任务同步,前端无法查询
- 应提供查询端点以便前端展示邮件投递状态
Issue 5: 端点命名不一致
v1/customAlerts(camelCase) vsv1/unsubscribed-emails(kebab-case) vsv1/invitation-templates(kebab-case)- 应统一为 kebab-case
Issue 6: 退订接口路径设计混乱
- Planner 管理:
v1/unsubscribed-emails - Attendee 自助:
v1/public/attendees/{attendeeId}/unsubscribed-email(单数) - Attendee 操作:
v1/public/attendees/{attendeeId}/unsubscribed-emails/{id}/unsubscribe(复数) - 单复数不一致
5. Frontend Analysis
5.1 Pages & Components
Plannerview (pharmagin-plannerview)
| 组件 | 路径 | 功能 |
|---|---|---|
Communication | components/Communication/index.js | 通信主页,含 4 个 Tab: Sent Items / Drafts / Scheduled Emails / Deleted Items |
MailItems | components/Communication/MailItems.js | 邮件列表项展示 |
MailContent | components/Communication/MailContent.js | 邮件内容查看 |
MailForm | components/Communication/MailForm.js | 邮件编辑/撰写表单 (富文本编辑器) |
ScheduledEmails | components/Communication/ScheduledEmails.js | 定时邮件管理 |
ScheduledEmailForm | components/Communication/ScheduledEmailForm.js | 定时邮件编辑表单 |
ScheduledEmailInstanceView | components/Communication/ScheduledEmailInstanceView.js | 定时邮件发送记录查看 |
Unsubscribe | components/Communication/Unsubscribe.js | 邮件退订信息展示 (Communication Tab 内) |
Resubscribe | components/Communication/Resubscribe.js | 重新订阅操作 |
UnsubscribedList | components/UnsubscribedList/index.js | 退订名单管理页 (Admin 级别) |
PushAlert | components/PushAlert/index.js | 自定义告警管理 (Admin 级别,含 CRUD Modal) |
EmailInvitationTemplates | components/EmailInvitationTemplates/index.js | 邀请模板管理 |
EmailInvitationTemplateForm | components/EmailInvitationTemplates/EmailInvitationTemplateForm.js | 邀请模板编辑表单 |
PreviewModal | components/EmailInvitationTemplates/PreviewModal.js | 邀请模板预览 |
SetUrlModal | components/EmailInvitationTemplates/SetUrlModal.js | 设置 URL |
UploadAttachmentModal | components/EmailInvitationTemplates/UploadAttachmentModal.js | 上传附件 |
EmailSandbox | components/EmailSandbox/index.js | 邮件沙盒设置 (Admin 级别) |
ForceTargetInvitation | components/ForceTargetInvitation/index.js | 强制 target 邀请配置 |
InvitationTrackReport | components/RegReport/InvitationTrackReport.js | 邀请追踪报告 |
Policy related | components/Policy/ | 各种邮件策略配置组件 |
Speakerview (pharmagin-speakerview)
| 组件 | 路径 | 功能 |
|---|---|---|
AlertList | components/Alert/index.js | Speaker 端告警列表,支持选择删除,点击触发行动 |
AlertModal | components/Alert/AlertModal.js | 告警详情弹窗 (training/presentation/expense/video) |
Salesview (pharmagin-salesview)
| 组件 | 路径 | 功能 |
|---|---|---|
AlertModal | components/Header/alertModal.js | Sales 端告警弹窗,支持 8 种 alertType 的不同行为 |
5.2 Redux State Structure
PushAlert (Custom Alert Management)
state.pushAlert = {
customAlerts: {
list: [], // CustomAlertDTO[]
pagination: {} // { current, pageSize, total }
},
customAlertsLoading: boolean,
customAlertSuccess: boolean,
customAlertRetrieve: boolean,
customAlertDelete: boolean,
}- Actions:
getCustomAlerts,savaAndUpdateCustomAlert,activeCustomAlert,deleteCustomAlert,downloadFileById,resetCustomAlertSuccess,resetCustomAlertRetrieve - Sagas:
components/PushAlert/sagas.js
EmailInvitationTemplates
state.emailInvitationTemplates = {
templates: {
list: [], // InvitationTemplatesResponse[]
pagination: {}
},
templatesLoading: boolean,
// CRUD success flags
}- Actions:
components/EmailInvitationTemplates/actions.js - Sagas:
components/EmailInvitationTemplates/sagas.js
ForceTargetInvitation
state.forceTargetInvitation = {
config: {}, // ForceTargetInvitationConfig
loading: boolean,
}- Actions:
components/ForceTargetInvitation/actions.js - Sagas:
components/ForceTargetInvitation/sagas.js
Communication (Hooks-based, no Redux)
Communication/index.js使用 React Hooks (useState,useEffect) 直接调用 API- 不使用 Redux store,状态管理在组件内部
EmailSandbox (Hooks-based, no Redux)
EmailSandbox/index.js使用 React Hooks 直接调用 API- 不使用 Redux store
Salesview Alerts
state.app = {
alerts: [], // { alertId, alertType, alertName, alertUrl, description, fileId, extra, programType }
alertHistory: [],
}- Actions:
getAlerts,setAlertHistory - API:
GET /api/v1/programs/{productId}/alerts
5.3 Frontend Issues
Issue 1: Communication 组件不使用 Redux,与其他组件风格不一致
Communication/index.js使用 Hooks + 直接 API 调用PushAlert/index.js和EmailInvitationTemplates/index.js使用传统 Redux + Saga 模式- 同一个域内两种状态管理方式共存
Issue 2: PushAlert 组件使用 deprecated 的 componentWillReceiveProps
PushAlert/index.js:62:componentWillReceiveProps(nextProps)在 React 16.3+ 中已被标记为 unsafe- 应迁移到
getDerivedStateFromProps或使用 Hooks 重写
Issue 3: Salesview AlertModal 的 alertType 使用 magic numbers
alertModal.js:42-98: 通过if/else if链判断 0-7 共 8 种 alertType- 没有使用枚举常量或 switch/case,可维护性差
Issue 4: EmailSandbox 中 useEffect 依赖可能导致无限循环
EmailSandbox/index.js:19-28:useEffect监听enabled变化并自动调用saveSettings()- 但
saveSettings()中没有setEnabled(),所以实际上不会无限循环,但saveSettings引用了emails和enabled状态,而这些值在 effect 依赖中没有列出 (可能导致 stale closure 问题)
Issue 5: PushAlert 的表单类型值使用了未定义的 type=2
PushAlert/index.js:393: Radio 选项包含<Radio value={2}>Other File</Radio>- 但后端
CustomAlert.type注释只定义了0: file, 1: url - type=2 在后端没有对应处理逻辑
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
| # | 问题 | 严重性 | 影响范围 | 引用位置 |
|---|---|---|---|---|
| C1 | Mail 表职责混乱: 同一张表同时存储通信邮件和邀请模板,通过 meetingId/productId 的有无隐式区分 | Critical | 数据架构 | entity/Mail.java, CommunicationService.java, InvitationTemplateService.java |
| C2 | Alert 系统碎片化: 5 种不同的告警实体,无统一模型,分散在不同模块。Speaker 端 (Alert)、Sales 端 (mixed query)、Planner 端 (MeetingAlert) 各自独立 | Critical | 全平台 | Alert.java, CustomAlert.java, MeetingAlert.java, BudgetAlert.java, alertModal.js |
| C3 | 硬编码 DST 时间表: iCalendar 生成中手动硬编码 2017-2030 的夏令时转换,2031 年后将失效 | Critical | iCalendar/日历邀请 | MailService.java:639-658 |
| C4 | 待审批提醒硬编码 productId=24: 只有一个客户能收到待审批提醒 | Critical | 审批流程 | MeetingTask.java:54 |
| C5 | 退订检查不全面: 只在 CommunicationService 中过滤退订用户,确认邮件、取消邮件、定时邮件 reminder 等路径不检查 | Critical | 合规性 | CommunicationService.java:370, MailService.java |
| C6 | 邮件发送无重试和状态追踪: @Async 方式发送,无法知道是否成功,无重试机制,无发送状态记录 | Critical | 邮件可靠性 | MailService.java:109 |
6.2 Design Defects (should improve)
| # | 问题 | 严重性 | 影响范围 | 引用位置 |
|---|---|---|---|---|
| D1 | config 字段无类型定义: 多个实体使用 Object 类型存储 JSON,无编译时类型检查 | Medium | 可维护性 | Mail.java:87, ScheduledEmail.java:44, EmailSandbox.java:31 |
| D2 | mailTo 使用分号分隔字符串: 应使用关联表或 JSON 数组 | Medium | 数据模型 | Mail.java:65, CommunicationService.java:358 |
| D3 | Sandbox 模式频繁查询数据库: 每封邮件发送时查询两次 sandbox 配置 | Medium | 性能 | MailService.java:126, EmailSandboxService.java:60 |
| D4 | 退订上传静默吞异常: DuplicateKeyException 被空 catch 块吞没 | Medium | 用户体验 | UnsubscribedEmailService.java:117 |
| D5 | sendCancellationMail 重用 MimeMessage: 循环中重用消息对象发给多个 planner | Medium | 邮件正确性 | MailService.java:376-389 |
| D6 | DELETE MeetingAlert 无条件删除所有记录: 缺少用户级别过滤 | Medium | 数据安全 | MeetingController.java:153-156 |
| D7 | API 命名风格不一致: camelCase vs kebab-case 混用 | Medium | API 规范 | customAlerts vs unsubscribed-emails |
| D8 | SendGridController 为空: 同步的数据无法通过 API 查询 | Medium | 功能完整性 | SendGridController.java:11 |
6.3 Technical Debt (nice to have)
| # | 问题 | 严重性 | 影响范围 | 引用位置 |
|---|---|---|---|---|
| T1 | StrSubstitutor 已废弃: 使用 commons-lang3 中已弃用的 API | Low | 依赖升级 | CommunicationService.java:527 |
| T2 | Alert 实体未使用 Lombok: Alert.java, CustomAlert.java, MeetingAlert.java 手写 getter/setter | Low | 代码风格 | Alert.java:34-135, CustomAlert.java:41-173 |
| T3 | Swagger 注解错误: Preview 端点的 httpMethod 标记为 DELETE | Low | API 文档 | CommunicationController.java:79 |
| T4 | componentWillReceiveProps 已弃用: PushAlert 使用 React deprecated lifecycle | Low | 前端 | PushAlert/index.js:62 |
| T5 | Magic numbers in frontend: Salesview alertType 0-7 无枚举定义 | Low | 可维护性 | alertModal.js:42-98 |
| T6 | 调查邮件模板硬编码 productId: AttendeeSurveyEmail_{productId}.html 格式,13 个产品特定模板文件 | Low | 可扩展性 | SurveyMailService.java:82-86 |
| T7 | SimpleDateFormat 非线程安全: SendGridService 中作为实例变量使用 | Low | 并发安全 | SendGridService.java:41 |
7. Rewrite Recommendations
7.1 数据模型重构
拆分 Mail 表: 将
t_mail拆分为:communication_email-- 通信邮件记录 (关联 meeting)invitation_template-- 邀请模板 (关联 product)- 这消除了实体职责混乱问题,也让查询逻辑更清晰
统一 Alert 模型: 创建统一的
notification表:notification ( id, type, category, title, description, target_user_type, target_user_id, source_type, source_id, action_url, action_type, status (unread/read/dismissed), created_at, read_at )- 取代
t_alert,t_custom_alert,t_meeting_alert,t_budget_alert及其 view_history 表 - 所有告警统一存储,统一查询,统一已读管理
- 取代
结构化 JSON 字段: 为 config 字段定义 Java DTO:
ScheduledEmailConfig { boolean scheduleEmail, int sendType, Integer beforeDays, Integer afterDays, ... }ScheduledEmailFilter { List<Integer> attendeeTypeIds, List<Integer> registrationStatus, ... }
修复 mailTo 字段: 使用关联表
communication_email_recipient(email_id, attendee_id)替代分号分隔字符串
7.2 邮件发送架构改进
引入邮件队列: 使用消息队列 (RabbitMQ/Redis) 替代
@Async:- 提供发送状态追踪 (pending/sent/failed/bounced)
- 支持失败重试 (exponential backoff)
- 解耦邮件发送与业务逻辑
统一退订检查: 在 MailService 层面 (而非 CommunicationService) 统一检查退订名单,确保所有邮件发送路径都受退订约束
修复 DST 处理: 使用
java.time.ZoneId和ZonedDateTime替代硬编码的夏令时表抽取邮件模板引擎: 将模板变量替换逻辑从 CommunicationService 中抽取为独立的
EmailTemplateEngine,支持:- 变量注册与验证
- 预览渲染
- 缺失变量报告
7.3 SendGrid 集成改进
- 双向集成: 除了同步邮件活动数据外,考虑通过 SendGrid API 发送邮件 (替代 SMTP),获得更好的投递追踪
- 提供查询 API: 为 SendGridController 添加查询端点,让前端可以展示邮件投递状态 (opens, clicks 等)
- Webhook 集成: 使用 SendGrid Event Webhook 替代定时轮询,实现实时邮件事件通知
7.4 前端统一
- 统一状态管理: Communication 域的所有组件统一使用相同的状态管理模式 (推荐全部迁移到 Hooks + Context 或新的状态管理库)
- 定义告警类型枚举: 在前后端都定义明确的告警类型常量,消除 magic numbers
- 提取通用邮件编辑器组件: MailForm 和 EmailInvitationTemplateForm 有大量重复逻辑 (富文本编辑、变量插入、附件上传),应抽取为共享组件
7.5 Thymeleaf 模板管理
当前有 35 个 HTML 模板文件分散在 resources/templates/ 下:
- 13 个产品特定的调查邮件模板 (
AttendeeSurveyEmail_{productId}.html) - 2 个程序会议提醒模板 (
meeting-reminder.html,meeting-reminder-51.html) - 其余为各种通知模板
建议:
- 将产品特定模板迁移到数据库或配置系统,消除硬编码
- 建立模板版本管理和预览机制
- 统一模板的变量命名规范 (当前
%%VAR%%和 Thymeleaf${var}混用)