Skip to content

Communication & Notification Domain - Deep Dive Analysis

1. Domain Overview

1.1 领域职责描述

Communication & Notification 领域负责整个 Speaker Platform 中所有与通信、通知相关的功能。其核心职责包括:

  1. 邮件通信 (Email Communication): 为 planner 提供向与会者 (attendees) 发送邮件的能力,支持草稿、发送、转发、预览、模板变量替换等完整邮件工作流
  2. 邮件邀请 (Email Invitation): 基于 service type 配置的邀请模板,向与会者发送标准化的活动邀请邮件,支持邀请追踪
  3. 定时邮件 (Scheduled Email): 支持按条件定时发送邮件,包含"立即发送"和"定时任务"两种模式
  4. 调查问卷邮件 (Survey Email): 向与会者发送活动后的调查问卷链接
  5. 提醒系统 (Reminder System): 多种提醒机制,包括注册提醒、待审批提醒、邀请未注册提醒
  6. 告警系统 (Alert System): 双重告警体系 -- Speaker 端的 Alert (任务提醒) 和 Planner/Sales 端的 MeetingAlert + CustomAlert + BudgetAlert (业务告警)
  7. 退订管理 (Unsubscribe Management): 管理邮件退订名单,在发送邮件时自动过滤退订用户
  8. SendGrid 集成: 通过 SendGrid API 同步邮件活动数据(打开率、点击率等)
  9. 邮件沙盒 (Email Sandbox): 测试环境下将所有邮件重定向到指定测试邮箱
  10. iCalendar 生成: 为活动邀请生成日历附件

1.2 涉及的后端模块和包

模块路径文件数职责
mailmodules/v1/mail/21邮件发送核心、退订管理、沙盒模式、调查邮件
communicationmodules/v1/communication/10邮件通信 CRUD、邀请发送、定时邮件、模板变量替换
sendgridmodules/v1/sendgrid/3SendGrid API 集成、邮件活动数据同步
alertmodules/v1/alert/5自定义告警 (CustomAlert) CRUD
remindermodules/v1/reminder/3Speaker 端提醒聚合
invitationtemplatemodules/v1/invitationtemplate/4邀请模板管理
定时任务common/task/4个相关文件MailTask, MeetingTask, SendGridTask, InvitedAttendeeReminderTask
实体层common/persistence/entity/15+ 个相关实体数据模型定义

2. Data Model Analysis

2.1 Entity Overview Table

实体类表名主要字段文件位置用途
Mailt_mailmailId, meetingId, mailType, mailFrom, mailSubject, emailInfo, mailTo, mailCc, mailBcc, attachment, mailAlias, headerImg, footerImg, backgroundImage, productId, enableInvitationTrack, config, status, modifiedDate, sentDate, priorityentity/Mail.java:22邮件记录 (双重职责: 通信邮件 + 邀请模板)
MailServiceTypet_mail_service_typeid, mailId, serviceTypeIdentity/MailServiceType.java:21邮件模板与 service type 的关联
ScheduledEmailt_scheduled_emailid, meetingId, subject, content, fromAlias, attachment, config(jsonb), status, deleted, createdAt/By, updatedAt/Byentity/ScheduledEmail.java:22定时邮件模板定义
ScheduledEmailInstancet_scheduled_email_instanceid, meetingId, emailId, type(0:scheduled/1:sendNow), subject, content, fromAlias, attachment, config(jsonb), createdAt/Byentity/ScheduledEmailInstance.java:22定时邮件发送记录
UnsubscribedEmailt_unsubscribed_emailid, productId, firstName, lastName, email, npi, status(0:unsubscribed/1:resubscribed), createdAt/By, updatedAt/Byentity/UnsubscribedEmail.java:22邮件退订记录
EmailSandboxt_email_sandboxid, enabled, emails(jsonb)entity/EmailSandbox.java:21沙盒模式配置 (全局唯一记录 id=1)
SendGridEmailActivitysendgrid_email_activityid, msgId, fromEmail, toEmail, subject, status, opensCount, clicksCount, lastEventTime, createdAt, updatedAtentity/SendGridEmailActivity.java:23SendGrid 邮件活动追踪数据
InvitationHistoryt_invitation_historyid, mailId, attendeeId, meetingId, readTime, accessTime, createdAtentity/InvitationHistory.java:22邀请邮件追踪记录
Alertt_alertid, name, type(0:training/1:presentation/2:expense/3:survey), objectId, speakerId, createdAt, companyIdentity/Alert.java:8Speaker 端任务告警
CustomAlertt_custom_alertid, productId, linkText, description, fileId, status(0:inactive/1:active), createdAt, type(0:file/1:url), urlentity/CustomAlert.java:8自定义推送告警 (由 planner 管理)
CustomAlertViewHistoryt_custom_alert_view_historyid, customAlertId, portalUserId, createdAt, userIdentity/CustomAlertViewHistory.java:22自定义告警查看记录
MeetingAlertt_meeting_alertid, meetingRequestId, name, createdAtentity/MeetingAlert.java:8会议相关告警 (取消、签到关闭等)
BudgetAlertt_budget_alertid, fiscalPeriod, amount, regionId, createdAtentity/BudgetAlert.java:23预算告警
BudgetAlertViewHistoryt_budget_alert_view_historyid, budgetAlertId, portalUserId, createdAt, userIdentity/BudgetAlertViewHistory.java:22预算告警查看记录
ForceTargetInvitationt_force_target_invitationmeetingRequestId, targetIdentity/ForceTargetInvitation.java:21强制 target 邀请关联
ForceTargetInvitationConfigt_force_target_invitation_configteamId, minimumCount, roles, status, createdAt/By, updatedAt/Byentity/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 != nullmailType = 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:
    java
    Map<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 type
  • CommunicationService.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 -- 自定义告警 CRUD
  • ProgramAlertService.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-36meetingId (NotNull), emailInfo (NotBlank), mailTo (NotBlank)
SendGrid 实体校验SendGridEmailActivity.java:31-57msgId, fromEmail, toEmail (NotBlank+Email), status (NotBlank), lastEventTime (NotNull)
邀请发送前置条件CommunicationService.java:641-651必须有对应 serviceType 的邀请模板、注册站点必须为 Active 状态
退订上传校验UnsubscribedEmailService.java:93-97Product name 必须能匹配到已有 product
自定义告警表单PushAlert/index.js:339-438productId, linkText, description 必填; type=url 时 url 必填; type=file 时 fileName 必填
邮件收件人校验MailService.java:157-160finalToAddressesList 为空时直接 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() 中实现
  • MailServicesendMail()/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: 在循环中重用同一个 MimeMessageMimeMessageHelper 发送给多个 planner
  • 先设置 helper.setTo(plannerMail) 再发送,可能导致之前设置的 Text 内容残留

4. API Inventory

4.1 REST Endpoints Table

Mail Module (v1/mails)

MethodPathController方法描述
POST/v1/mails/confirmationMailController:78sendConfirmation()手动发送确认邮件
POST/v1/mails/surveyMailController:84sendSurvey()发送会议调查邮件
POST/v1/mails/program/surveyMailController:90sendProgramSurvey()发送项目调查邮件
POST/v1/mails/complianceMailController:179sendComplianceNotification()发送合规通知邮件

Communication Module (v1/communications)

MethodPathController方法描述
GET/v1/communicationsCommunicationController:42list()获取邮件列表 (按 meetingId + mailType)
GET/v1/communications/{mailId}CommunicationController:52mailDetail()获取邮件详情
PUT/v1/communications/{mailId}/use-invitation-templateCommunicationController:57useInvitationTemplate()将邮件关联到邀请模板
PUT/v1/communicationsCommunicationController:63saveDraft()保存草稿
POST/v1/communicationsCommunicationController:69sendMail()发送邮件
DELETE/v1/communicationsCommunicationController:75deleteMail()删除邮件 (逻辑删除)
POST/v1/communications/previewCommunicationController:81sendPreviewMail()发送预览邮件
POST/v1/communications/forwardCommunicationController:87forwardMail()转发邮件
POST/v1/communications/uploadCommunicationController:93uploadAttach()上传邮件附件
POST/v1/communications/upload-imageCommunicationController:99uploadImage()上传邮件内嵌图片
GET/v1/communications/reply-to-email-addressCommunicationController:104getReplyToEmailAddress()获取回复邮箱地址
GET/v1/communications/scheduled-emailsCommunicationController:109listScheduledEmails()获取定时邮件列表
POST/v1/communications/scheduled-emailsCommunicationController:114createScheduledEmail()创建定时邮件
PUT/v1/communications/scheduled-emails/{id}CommunicationController:119updateScheduledEmail()更新定时邮件
DELETE/v1/communications/scheduled-emails/{id}CommunicationController:124deleteScheduledEmail()删除定时邮件
PUT/v1/communications/scheduled-emails/{id}/activateCommunicationController:129activateScheduledEmail()激活定时邮件
PUT/v1/communications/scheduled-emails/{id}/deactivateCommunicationController:133deactivateScheduledEmail()停用定时邮件
GET/v1/communications/scheduled-emails/instancesCommunicationController:139listScheduledEmailInstances()获取定时邮件执行记录

Unsubscribed Email Module (v1/unsubscribed-emails)

MethodPathController方法描述
GET/v1/unsubscribed-emailsUnsubscribedEmailController:28listUnsubscribedEmails()获取退订列表
POST/v1/unsubscribed-emails/uploadUnsubscribedEmailController:33upload()批量上传退订名单
GET/v1/unsubscribed-emails/downloadUnsubscribedEmailController:38download()下载退订列表
PUT/v1/unsubscribed-emails/statusUnsubscribedEmailController:43updateStatus()批量更新退订状态
PUT/v1/unsubscribed-emails/{id}UnsubscribedEmailController:48update()更新退订记录

Attendee Unsubscribe (Public, v1/public/attendees/{attendeeId})

MethodPathController方法描述
GET/v1/public/attendees/{attendeeId}/unsubscribed-emailAttendeeUnsubscribedEmailController:21getUnsubscribedEmailByAttendeeId()查询退订状态
POST/v1/public/attendees/{attendeeId}/unsubscribed-emailAttendeeUnsubscribedEmailController:26addAttendeeEmailToUnsubscribedList()添加到退订列表
PUT/v1/public/attendees/{attendeeId}/unsubscribed-emails/{id}/unsubscribeAttendeeUnsubscribedEmailController:32unsubscribe()退订
PUT/v1/public/attendees/{attendeeId}/unsubscribed-emails/{id}/resubscribeAttendeeUnsubscribedEmailController:38resubscribe()重新订阅

Email Sandbox (v1/email-sandbox)

MethodPathController方法描述
GET/v1/email-sandbox/settingsEmailSandboxController:20getEmailSandboxSettings()获取沙盒设置
POST/v1/email-sandbox/settingsEmailSandboxController:25updateEmailSandboxSettings()更新沙盒设置

Custom Alert (v1/customAlerts)

MethodPathController方法描述
GET/v1/customAlertsCustomAlertController:36listCustomAlerts()获取自定义告警列表 (分页)
POST/v1/customAlertsCustomAlertController:42save()创建自定义告警
PUT/v1/customAlerts/{id}CustomAlertController:48update()更新自定义告警
PUT/v1/customAlerts/{id}/statusCustomAlertController:54updateStatus()更新告警状态
GET/v1/customAlerts/{id}CustomAlertController:61getDetailById()获取告警详情
DELETE/v1/customAlerts/{id}CustomAlertController:67delete()删除自定义告警 (物理删除)

Speaker Reminder (v1/speakers/{speakerId}/reminders)

MethodPathController方法描述
GET/v1/speakers/{speakerId}/remindersSpeakerReminderController:20getReminders()获取 Speaker 提醒状态

Invitation Templates (v1/invitation-templates)

MethodPathController方法描述
GET/v1/invitation-templatesInvitationTemplateController:26listInvitationTemplates()获取邀请模板列表 (分页)
POST/v1/invitation-templatesInvitationTemplateController:31createInvitationTemplate()创建邀请模板
PUT/v1/invitation-templates/{id}InvitationTemplateController:36updateInvitationTemplate()更新邀请模板
DELETE/v1/invitation-templates/{id}InvitationTemplateController:41deleteInvitationTemplate()删除邀请模板 (物理删除)
PUT/v1/invitation-templates/{id}/templatesInvitationTemplateController:46setTemplate()关联 service types

Meeting Alerts (within MeetingController)

MethodPathController方法描述
GET/v1/meetings/alertsMeetingController:136listMeetingAlerts()获取当前用户的会议告警列表
DELETE/v1/meetings/alertsMeetingController:153deletesMeetingAlerts()删除所有会议告警

SendGrid (empty controller, backend-only sync)

MethodPathController方法描述
(无 REST endpoint)-SendGridController:11-控制器为空,SendGrid 数据同步仅通过定时任务执行

Cron Job Triggers

MethodPathController方法描述
GET/v1/cronjobs/send-invited-attendee-reminder-to-plannersCronJobsController:23sendInvitedAttendeeReminder()手动触发邀请提醒

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) vs v1/unsubscribed-emails (kebab-case) vs v1/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)

组件路径功能
Communicationcomponents/Communication/index.js通信主页,含 4 个 Tab: Sent Items / Drafts / Scheduled Emails / Deleted Items
MailItemscomponents/Communication/MailItems.js邮件列表项展示
MailContentcomponents/Communication/MailContent.js邮件内容查看
MailFormcomponents/Communication/MailForm.js邮件编辑/撰写表单 (富文本编辑器)
ScheduledEmailscomponents/Communication/ScheduledEmails.js定时邮件管理
ScheduledEmailFormcomponents/Communication/ScheduledEmailForm.js定时邮件编辑表单
ScheduledEmailInstanceViewcomponents/Communication/ScheduledEmailInstanceView.js定时邮件发送记录查看
Unsubscribecomponents/Communication/Unsubscribe.js邮件退订信息展示 (Communication Tab 内)
Resubscribecomponents/Communication/Resubscribe.js重新订阅操作
UnsubscribedListcomponents/UnsubscribedList/index.js退订名单管理页 (Admin 级别)
PushAlertcomponents/PushAlert/index.js自定义告警管理 (Admin 级别,含 CRUD Modal)
EmailInvitationTemplatescomponents/EmailInvitationTemplates/index.js邀请模板管理
EmailInvitationTemplateFormcomponents/EmailInvitationTemplates/EmailInvitationTemplateForm.js邀请模板编辑表单
PreviewModalcomponents/EmailInvitationTemplates/PreviewModal.js邀请模板预览
SetUrlModalcomponents/EmailInvitationTemplates/SetUrlModal.js设置 URL
UploadAttachmentModalcomponents/EmailInvitationTemplates/UploadAttachmentModal.js上传附件
EmailSandboxcomponents/EmailSandbox/index.js邮件沙盒设置 (Admin 级别)
ForceTargetInvitationcomponents/ForceTargetInvitation/index.js强制 target 邀请配置
InvitationTrackReportcomponents/RegReport/InvitationTrackReport.js邀请追踪报告
Policy relatedcomponents/Policy/各种邮件策略配置组件

Speakerview (pharmagin-speakerview)

组件路径功能
AlertListcomponents/Alert/index.jsSpeaker 端告警列表,支持选择删除,点击触发行动
AlertModalcomponents/Alert/AlertModal.js告警详情弹窗 (training/presentation/expense/video)

Salesview (pharmagin-salesview)

组件路径功能
AlertModalcomponents/Header/alertModal.jsSales 端告警弹窗,支持 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.jsEmailInvitationTemplates/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 引用了 emailsenabled 状态,而这些值在 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)

#问题严重性影响范围引用位置
C1Mail 表职责混乱: 同一张表同时存储通信邮件和邀请模板,通过 meetingId/productId 的有无隐式区分Critical数据架构entity/Mail.java, CommunicationService.java, InvitationTemplateService.java
C2Alert 系统碎片化: 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 年后将失效CriticaliCalendar/日历邀请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)

#问题严重性影响范围引用位置
D1config 字段无类型定义: 多个实体使用 Object 类型存储 JSON,无编译时类型检查Medium可维护性Mail.java:87, ScheduledEmail.java:44, EmailSandbox.java:31
D2mailTo 使用分号分隔字符串: 应使用关联表或 JSON 数组Medium数据模型Mail.java:65, CommunicationService.java:358
D3Sandbox 模式频繁查询数据库: 每封邮件发送时查询两次 sandbox 配置Medium性能MailService.java:126, EmailSandboxService.java:60
D4退订上传静默吞异常: DuplicateKeyException 被空 catch 块吞没Medium用户体验UnsubscribedEmailService.java:117
D5sendCancellationMail 重用 MimeMessage: 循环中重用消息对象发给多个 plannerMedium邮件正确性MailService.java:376-389
D6DELETE MeetingAlert 无条件删除所有记录: 缺少用户级别过滤Medium数据安全MeetingController.java:153-156
D7API 命名风格不一致: camelCase vs kebab-case 混用MediumAPI 规范customAlerts vs unsubscribed-emails
D8SendGridController 为空: 同步的数据无法通过 API 查询Medium功能完整性SendGridController.java:11

6.3 Technical Debt (nice to have)

#问题严重性影响范围引用位置
T1StrSubstitutor 已废弃: 使用 commons-lang3 中已弃用的 APILow依赖升级CommunicationService.java:527
T2Alert 实体未使用 Lombok: Alert.java, CustomAlert.java, MeetingAlert.java 手写 getter/setterLow代码风格Alert.java:34-135, CustomAlert.java:41-173
T3Swagger 注解错误: Preview 端点的 httpMethod 标记为 DELETELowAPI 文档CommunicationController.java:79
T4componentWillReceiveProps 已弃用: PushAlert 使用 React deprecated lifecycleLow前端PushAlert/index.js:62
T5Magic numbers in frontend: Salesview alertType 0-7 无枚举定义Low可维护性alertModal.js:42-98
T6调查邮件模板硬编码 productId: AttendeeSurveyEmail_{productId}.html 格式,13 个产品特定模板文件Low可扩展性SurveyMailService.java:82-86
T7SimpleDateFormat 非线程安全: SendGridService 中作为实例变量使用Low并发安全SendGridService.java:41

7. Rewrite Recommendations

7.1 数据模型重构

  1. 拆分 Mail 表: 将 t_mail 拆分为:

    • communication_email -- 通信邮件记录 (关联 meeting)
    • invitation_template -- 邀请模板 (关联 product)
    • 这消除了实体职责混乱问题,也让查询逻辑更清晰
  2. 统一 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 表
    • 所有告警统一存储,统一查询,统一已读管理
  3. 结构化 JSON 字段: 为 config 字段定义 Java DTO:

    • ScheduledEmailConfig { boolean scheduleEmail, int sendType, Integer beforeDays, Integer afterDays, ... }
    • ScheduledEmailFilter { List<Integer> attendeeTypeIds, List<Integer> registrationStatus, ... }
  4. 修复 mailTo 字段: 使用关联表 communication_email_recipient(email_id, attendee_id) 替代分号分隔字符串

7.2 邮件发送架构改进

  1. 引入邮件队列: 使用消息队列 (RabbitMQ/Redis) 替代 @Async:

    • 提供发送状态追踪 (pending/sent/failed/bounced)
    • 支持失败重试 (exponential backoff)
    • 解耦邮件发送与业务逻辑
  2. 统一退订检查: 在 MailService 层面 (而非 CommunicationService) 统一检查退订名单,确保所有邮件发送路径都受退订约束

  3. 修复 DST 处理: 使用 java.time.ZoneIdZonedDateTime 替代硬编码的夏令时表

  4. 抽取邮件模板引擎: 将模板变量替换逻辑从 CommunicationService 中抽取为独立的 EmailTemplateEngine,支持:

    • 变量注册与验证
    • 预览渲染
    • 缺失变量报告

7.3 SendGrid 集成改进

  1. 双向集成: 除了同步邮件活动数据外,考虑通过 SendGrid API 发送邮件 (替代 SMTP),获得更好的投递追踪
  2. 提供查询 API: 为 SendGridController 添加查询端点,让前端可以展示邮件投递状态 (opens, clicks 等)
  3. Webhook 集成: 使用 SendGrid Event Webhook 替代定时轮询,实现实时邮件事件通知

7.4 前端统一

  1. 统一状态管理: Communication 域的所有组件统一使用相同的状态管理模式 (推荐全部迁移到 Hooks + Context 或新的状态管理库)
  2. 定义告警类型枚举: 在前后端都定义明确的告警类型常量,消除 magic numbers
  3. 提取通用邮件编辑器组件: MailForm 和 EmailInvitationTemplateForm 有大量重复逻辑 (富文本编辑、变量插入、附件上传),应抽取为共享组件

7.5 Thymeleaf 模板管理

当前有 35 个 HTML 模板文件分散在 resources/templates/ 下:

  • 13 个产品特定的调查邮件模板 (AttendeeSurveyEmail_{productId}.html)
  • 2 个程序会议提醒模板 (meeting-reminder.html, meeting-reminder-51.html)
  • 其余为各种通知模板

建议:

  1. 将产品特定模板迁移到数据库或配置系统,消除硬编码
  2. 建立模板版本管理和预览机制
  3. 统一模板的变量命名规范 (当前 %%VAR%% 和 Thymeleaf ${var} 混用)