ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

消息通知怎么设计?站内信、邮件和短信不要乱发

消息通知怎么设计?站内信、邮件和短信不要乱发 企业系统里的消息通知很容易从“提醒用户及时处理”变成“到处弹、到处发、没人看”。站内信、邮件、短信、企业微信、钉钉、App 推送都能发但真正难的是什么事件值得通知通知谁用哪个渠道失败要不要重试用户能不能退订业务动作是否会被通知失败拖垮。通知系统不是把消息发出去这么简单。它连接业务事件、用户偏好、渠道能力、成本控制和审计追踪。设计得好通知能帮用户减少遗漏设计得差通知会变成新的噪音和投诉来源。本文用一个常见场景做示例维修工单关闭后需要通知申请人和资产管理员。申请人只需要站内信和邮件资产管理员需要站内信如果邮件失败不应该影响工单关闭如果同一工单被重复关闭请求触发也不能反复轰炸用户。这个例子不局限于维修工单。审批待办、合同到期、库存预警、付款成功、导入失败、客户跟进超时都可以用同一套通知设计方法。差别只是事件类型、接收人规则、渠道优先级和频控策略不同。示例环境Java 17、Spring Boot 风格服务层、MySQL 8.x。本文会给出表结构、轻量代码骨架、测试思路和 SQL 验证但重点放在通知策略本身不会把篇幅主要花在代码堆叠上。目录通知系统先回答六个问题输入案例维修工单关闭通知渠道选择站内信、邮件和短信各管什么MySQL表结构事件、收件人和发送记录实现骨架业务写outbox发送异步执行测试重点少测发送SDK多测策略边界SQL验证上线后查通知有没有乱发异常边界和上线验收小结和延伸阅读一、通知系统先回答六个问题设计通知之前先不要急着接短信平台或邮件服务器。更重要的是回答六个问题。问题设计含义没想清楚的后果什么事件要通知只选用户需要及时知道的业务变化系统到处发消息用户屏蔽所有通知通知谁根据业务关系和角色计算接收人漏发关键人或把内部信息发给无关人用什么渠道站内信、邮件、短信各有边界低价值消息走高成本渠道什么时候发实时、延迟、汇总、工作时间发送半夜短信扰民或提醒太晚失效失败怎么办重试、降级、人工查看通知失败拖垮主业务或失败无人发现能否退订和频控尊重用户偏好和成本限制同一事件反复提醒用户反感这六个问题比代码更重要。很多系统通知混乱不是因为发送接口难写而是因为事件和渠道没有边界。比如“审批待办”可能要实时站内信“合同明天到期”可以每天早上汇总邮件“验证码”才适合短信。图1通知设计要先明确事件、收件人、渠道、时机、失败处理和频控。二、输入案例维修工单关闭通知本文固定一组输入后面的数据模型、代码骨架和 SQL 验证都围绕它展开。业务对象维修工单 WO-20260924-001 事件类型WORK_ORDER_CLOSED 事件标题维修工单已关闭 触发动作维修人员确认处理完成工单状态从 PROCESSING 变为 CLOSED 申请人user_id 1008 资产管理员user_id 2001 通知策略 申请人站内信 邮件 资产管理员站内信 短信不发送 幂等键WORK_ORDER_CLOSED:WO-20260924-001 失败策略邮件失败最多重试3次不影响工单关闭 频控策略同一工单关闭事件只通知一次这个案例里有几个关键点。第一通知来自业务事件而不是页面按钮。工单真正关闭后才写通知事件如果关闭失败不应该提前通知。第二接收人来自业务关系。申请人关心处理结果资产管理员关心台账状态其他人没有必要收到。第三不同渠道承担不同作用。站内信作为系统内留痕邮件作为离线提醒短信成本高且打扰强本案例不需要使用。第四通知失败不应该让业务回滚。工单关闭是主业务邮件只是提醒。正确做法是业务事务里写 outbox发送动作异步执行。三、渠道选择站内信、邮件和短信各管什么通知渠道不是越多越好。一个事件同时发站内信、邮件、短信、企业微信看起来很重视实际可能是在消耗用户耐心。站内信适合做系统内留痕。它成本低、可查询、可标记已读适合待办、处理结果、导入完成、审批结果等大多数业务通知。缺点是用户不登录系统就看不到。邮件适合做离线提醒和正式通知。它适合日报、周报、到期提醒、处理结果、外部协作信息。缺点是到达不稳定容易进垃圾箱不适合做强实时保证。短信适合做高优先级、短内容、强触达场景例如验证码、紧急告警、重要审批超时。短信有成本也容易打扰用户不应该用来发送普通状态变化。企业微信、钉钉、App 推送适合组织内部即时提醒但也要遵守频控和免打扰策略。不要因为接入方便就把所有站内消息同步到即时通讯工具。可以用一个简单分级来控制渠道消息等级示例推荐渠道普通留痕工单关闭、导入完成站内信需要离线提醒合同到期、审批待办站内信 邮件/企业微信紧急且高价值生产告警、支付异常站内信 短信/电话汇总类每日待办、周报邮件或站内汇总图2站内信、邮件、短信和即时通讯不要承担同一种通知任务。四、MySQL表结构事件、收件人和发送记录通知表不要只存“标题、内容、是否已读”。一个可靠通知系统至少要区分事件、收件人、渠道发送记录。CREATETABLEmsg_event_outbox(idBIGINTPRIMARYKEYAUTO_INCREMENT,company_idBIGINTNOTNULL,event_typeVARCHAR(80)NOTNULL,biz_typeVARCHAR(64)NOTNULL,biz_idBIGINTNOTNULL,biz_noVARCHAR(64)NOTNULL,event_keyVARCHAR(120)NOTNULL,titleVARCHAR(200)NOTNULL,contentTEXTNOTNULL,payload_json JSONNULL,statusVARCHAR(32)NOTNULL,next_retry_timeDATETIMENULL,retry_countINTNOTNULLDEFAULT0,create_timeDATETIMENOTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_msg_event_key(event_key),KEYidx_msg_event_status(status,next_retry_time));CREATETABLEmsg_recipient(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_idBIGINTNOTNULL,user_idBIGINTNOTNULL,recipient_roleVARCHAR(64)NOTNULL,channelsVARCHAR(100)NOTNULL,read_timeDATETIMENULL,create_timeDATETIMENOTNULL,UNIQUEKEYuk_msg_recipient(event_id,user_id),KEYidx_msg_user_unread(user_id,read_time));CREATETABLEmsg_delivery_log(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_idBIGINTNOTNULL,recipient_idBIGINTNOTNULL,user_idBIGINTNOTNULL,channelVARCHAR(32)NOTNULL,statusVARCHAR(32)NOTNULL,provider_msg_idVARCHAR(120)NULL,error_codeVARCHAR(80)NULL,error_messageVARCHAR(500)NULL,send_timeDATETIMENULL,create_timeDATETIMENOTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_msg_delivery_once(event_id,user_id,channel),KEYidx_msg_delivery_status(status,create_time));这三张表分工不同。msg_event_outbox表示一次业务事件。它解决的是“有没有一件事需要通知”。event_key是幂等键同一个工单关闭事件只能写一次。msg_recipient表示这次事件要通知哪些人。一个事件可以有多个收件人每个收件人可以有不同渠道。msg_delivery_log表示某个收件人在某个渠道上的发送状态。邮件失败、短信失败、站内信成功要分开记录后续才能重试和排查。图3通知事件、收件人和渠道发送记录拆开后才能做幂等、重试和审计。五、实现骨架业务写outbox发送异步执行通知不要在主业务事务里直接调用 SMTP、短信网关或第三方 API。更稳的方式是业务成功时写 outbox后台 worker 扫描待发送事件发送失败后按策略重试。业务侧只做一件事在业务状态变更成功时写事件。Transactional(rollbackForException.class)publicvoidcloseWorkOrder(CloseCommandcommand){WorkOrderorderworkOrderRepository.lockById(command.workOrderId());if(!PROCESSING.equals(order.status())){thrownewServiceException(当前工单不能关闭order.status());}workOrderRepository.close(order.id(),command.operatorId(),command.occurredAt());notificationOutboxRepository.insertIgnore(newNotificationEvent(order.companyId(),WORK_ORDER_CLOSED,WORK_ORDER,order.id(),order.workOrderNo(),WORK_ORDER_CLOSED:order.workOrderNo(),维修工单已关闭,你的维修工单已处理完成请查看处理结果。,command.occurredAt()));}发送侧负责策略算收件人、算渠道、生成发送记录、调用具体渠道。publicvoiddispatchOneEvent(LongeventId){NotificationEventeventoutboxRepository.lockPending(eventId);ListRecipientPlanrecipientsrecipientResolver.resolve(event);for(RecipientPlanrecipient:recipients){for(Stringchannel:recipient.channels()){if(preferenceService.isMuted(recipient.userId(),event.eventType(),channel)){deliveryLogRepository.skip(event.id(),recipient.userId(),channel,USER_MUTED);continue;}if(rateLimitService.exceeded(recipient.userId(),event.eventType(),channel)){deliveryLogRepository.skip(event.id(),recipient.userId(),channel,RATE_LIMITED);continue;}deliverySender.send(event,recipient,channel);}}outboxRepository.markDispatched(event.id());}这两段代码故意保持简短因为重点不在语法而在边界。业务事务只保证“事件被记录”。它不保证邮件一定已经发出。发送 worker 可以失败可以重试可以暂停也可以切换供应商。它不应该反向影响工单是否关闭。接收人和渠道不写死在业务代码里。真实系统可以从通知策略表、角色关系、用户偏好和业务对象关系里计算。图4业务事务写通知事件发送 worker 按策略异步处理渠道。六、测试重点少测发送SDK多测策略边界通知测试不要把主要精力放在第三方 SDK 是否真的能发邮件。SDK 的联通性可以用集成环境验证日常单元测试更应该覆盖策略边界。建议至少覆盖这些测试1. 工单关闭成功后只写入一条 WORK_ORDER_CLOSED 事件。 2. 同一 event_key 重复写入时不产生重复事件。 3. 申请人生成站内信和邮件资产管理员只生成站内信。 4. 用户退订邮件后邮件发送记录标记为 USER_MUTED。 5. 频控命中后发送记录标记为 RATE_LIMITED。 6. 邮件发送失败后retry_count 增加next_retry_time 后移。 7. 邮件失败不影响工单状态 CLOSED。如果要写成 JUnit可以聚焦一两个核心测试而不是把所有发送渠道都 mock 一遍。TestvoidshouldWriteOnlyOneEventWhenWorkOrderClosedTwice(){FixturefxFixture.processingWorkOrder(WO-20260924-001);fx.workOrderService().closeWorkOrder(fx.closeCommand());fx.workOrderService().closeWorkOrder(fx.sameRequestCommand());assertEquals(CLOSED,fx.workOrderRepository().status());assertEquals(1,fx.outboxRepository().countByEventKey(WORK_ORDER_CLOSED:WO-20260924-001));}TestvoidshouldSkipEmailWhenUserMutedThisEventType(){FixturefxFixture.event(WORK_ORDER_CLOSED);fx.preferenceService().mute(1008L,WORK_ORDER_CLOSED,EMAIL);fx.dispatcher().dispatchOneEvent(fx.eventId());assertEquals(SKIPPED,fx.deliveryLog().status(1008L,EMAIL));assertEquals(USER_MUTED,fx.deliveryLog().errorCode(1008L,EMAIL));assertEquals(SENT,fx.deliveryLog().status(1008L,INBOX));}这类测试比“调用邮件SDK返回 true”更有价值。因为通知系统真正容易出错的地方是重复、误发、漏发、退订不生效、失败重试失控。七、SQL验证上线后查通知有没有乱发通知上线后要能用 SQL 看清楚是否乱发、漏发和重试异常。检查同一事件是否重复SELECTevent_key,COUNT(*)AScntFROMmsg_event_outboxGROUPBYevent_keyHAVINGCOUNT(*)1;预期结果empty set检查邮件失败率SELECTchannel,status,COUNT(*)AScntFROMmsg_delivery_logWHEREcreate_timeDATE_SUB(NOW(),INTERVAL1DAY)GROUPBYchannel,status;这条不是要求失败为 0而是要能看趋势。如果邮件失败突然升高要排查 SMTP、模板参数、收件地址质量或供应商限制。检查是否有无限重试SELECTid,event_type,biz_no,retry_count,next_retry_timeFROMmsg_event_outboxWHEREretry_count3ANDstatusIN(PENDING,RETRY);预期结果empty set检查短信是否被普通事件滥用SELECTe.event_type,COUNT(*)ASsms_countFROMmsg_delivery_log dJOINmsg_event_outbox eONe.idd.event_idWHEREd.channelSMSANDd.create_timeDATE_SUB(NOW(),INTERVAL7DAY)GROUPBYe.event_typeORDERBYsms_countDESC;这条要人工判断。如果大量普通工单、普通审批都走短信就说明渠道策略失控了。图5通知验收要检查重复事件、失败率、重试次数和高成本渠道使用情况。八、异常边界和上线验收通知系统要提前讲清楚这些边界。业务成功但通知失败主业务不应该被通知失败回滚。应该记录失败状态进入重试或人工处理列表。通知成功但用户没看到系统只能证明消息已发送、已投递或已读不能保证用户真的理解了内容。关键任务不能只靠通知还要有待办列表。重复事件同一个业务事件必须有稳定幂等键。没有幂等键重复提交和重试会造成重复通知。用户退订非强制消息要尊重用户偏好。强制消息也要有明确范围比如安全告警、审批待办、合规通知。渠道降级邮件失败是否改发站内信短信失败是否改发邮件要按事件等级决定。不要所有失败都自动升级到短信。模板参数缺失模板参数缺失应当在发送前校验。不能让用户收到“{workOrderNo} 已关闭”这种半成品消息。上线验收可以按下面清单执行每类业务事件都有明确通知策略。普通通知、重要通知、紧急通知的渠道不同。同一事件有稳定event_key重复触发不会重复发送。业务事务只写 outbox不直接调用外部发送服务。站内信、邮件、短信的发送记录分开保存。用户偏好和退订规则能生效。频控能阻止短时间重复提醒。失败重试有次数上限和下次重试时间。模板参数缺失会阻止发送并记录错误。SQL 能查出重复事件、失败率、无限重试和短信滥用。九、小结和延伸阅读消息通知的目标不是“能发出去”而是“在合适的时间用合适的渠道把合适的信息发给合适的人”。这句话听起来像原则但落到系统里就是事件模型、收件人规则、渠道策略、outbox、幂等、退订、频控和发送日志。如果系统还很小可以先做站内信和 outbox等通知量上来再扩展邮件、短信和即时通讯。不要一开始把所有渠道都接上也不要让外部渠道失败影响主业务。把通知边界打稳后续再做模板管理、消息中心和运营统计会顺很多。延伸阅读MicrosoftTransactional Outbox patternSpring Framework声明式事务管理MySQL 8.4CREATE TABLE
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表