ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

站内信、短信、邮件、订阅消息各写一套?场景编排 + 多通道分发,新场景一行代码接入

站内信、短信、邮件、订阅消息各写一套?场景编排 + 多通道分发,新场景一行代码接入 站内信、短信、邮件、订阅消息各写一套场景编排 多通道分发新场景一行代码接入导读业务方三天两头加通知场景——支付成功要通知、审核通过要通知、活动上线要通知。如果每个场景都手写一遍查用户→选通道→拼文案→发送代码迟早烂掉。我后来把通知收敛成一个 Facade场景在后台配置代码里一行调用发全部渠道。先说痛点。项目里通知渠道越来越多站内信、短信、邮件、微信订阅消息。最早是各业务自己调支付模块直接 new 一个 SmsSender审核模块自己拼站内信后来发现三个问题同样的用户偏好不共享有人关了短信还不知道、模板散落各处、新场景上线要改代码。于是做了个统一的NotifyFacade业务方只认一个方法publicinterfaceNotifyFacade{NotifySendResultsend(LonguserId,Stringscene,Mapparams);}支付成功就一行notifyFacade.send(userId, PAY_SUCCESS, params)。至于走站内信还是短信还是都发后台配置说了算代码不用动。场景表一个场景对应多个通道核心是一张场景配置表业务只管传 scene 编码发哪些通道、用哪个模板全在这张表里TableName(qkl_notify_scene)publicclassSysNotifySceneextendsBaseEntity{TableIdprivateLongid;privateStringtenantId;/** 场景编码如 PAY_SUCCESS */privateStringscene;/** 通道列表逗号分隔INSITE,SMS,EMAIL */privateStringchannels;/** 站内信模板编码 */privateStringinsiteTemplateCode;/** 短信场景 */privateStringsmsScene;/** 邮件模板编码 */privateStringemailTemplateCode;/** 订阅消息模板编码 */privateStringmpSubscribeTemplateCode;/** 站内消息类型SYSTEM/ORDER/APPROVE/NOTICE */privateStringmsgType;/** 1 启用 0 停用 */privateIntegerstatus;}新加一个提现到账通知运营在后台插一条场景记录配好模板开发连发版都不用。用户通知档案通道级别的前置拦截在场景分发之前还有一个容易被忽略的环节用户维度是否允许这个通道。我们把用户-通道偏好收成了UserNotifyProfilesend()第一步就查它用户停用了直接抛错UserNotifyProfileuseruserQuery.findById(userId);if(usernull||(user.getStatus()!nulluser.getStatus()0)){thrownewQklBizException(ErrorCode.NOT_FOUND,接收用户不存在或已停用);}这里有个产品细节站内信默认必达短信/邮件/订阅消息才看偏好。因为站内信不花钱、没有骚扰成本用户在 App 里能看到就行短信一条一毛钱用户没绑手机号、或者明确关了短信就不该发。这套逻辑统一收在分发层业务方不用关心这个人能不能发短信。分发逻辑逐通道发送失败不影响其他通道send()的核心逻辑查场景配置 → 循环通道列表 → 逐个分发 → 每个通道的结果落发送日志publicNotifySendResultsend(LonguserId,Stringscene,Mapparams){UserNotifyProfileuseruserQuery.findById(userId);if(usernull||(user.getStatus()!nulluser.getStatus()0)){thrownewQklBizException(ErrorCode.NOT_FOUND,接收用户不存在或已停用);}StringtenantIdStringUtils.hasText(user.getTenantId())?user.getTenantId():0;SysNotifyScenesceneCfgrequireEnabledScene(tenantId,scene);ListchannelsparseChannels(sceneCfg.getChannels());NotifySendResult.NotifySendResultBuilderresultBuilderNotifySendResult.builder().scene(scene).userId(userId);ListdetailsnewArrayList();for(Stringchannel:channels){NotifySendResult.ChannelResultcrdispatchChannel(channel,sceneCfg,user,tenantId,params);details.add(cr);saveLog(tenantId,scene,userId,channel,cr,params);}resultBuilder.channels(details);returnresultBuilder.build();}分发的关键点是通道之间互相隔离一个挂了不影响其他privateNotifySendResult.ChannelResultdispatchChannel(Stringchannel,SysNotifyScenesceneCfg,UserNotifyProfileuser,StringtenantId,Mapparams){try{returnswitch(channel.toUpperCase()){caseNotifyChannel.INSITE-sendInsite(sceneCfg,user,tenantId,params);caseNotifyChannel.SMS-sendSms(sceneCfg,user,params);caseNotifyChannel.EMAIL-sendEmail(sceneCfg,user,tenantId,params);caseNotifyChannel.MP_SUBSCRIBE-sendMpSubscribe(sceneCfg,user,tenantId,params);default-NotifySendResult.ChannelResult.builder().channel(channel).status(SKIP).message(不支持的通道: channel).build();};}catch(Exceptione){log.warn([NOTIFY] 通道失败 scene{} channel{} userId{}: {},sceneCfg.getScene(),channel,user.getId(),e.getMessage());returnNotifySendResult.ChannelResult.builder().channel(channel).status(FAIL).message(truncate(e.getMessage())).build();}}每个通道发送前都有前置校验没绑手机号就 SKIP 短信没绑邮箱就 SKIP 邮件返回结果里能看到每个通道的最终状态privateNotifySendResult.ChannelResultsendSms(SysNotifyScenesceneCfg,UserNotifyProfileuser,Mapparams){if(!StringUtils.hasText(user.getPhone())){returnNotifySendResult.ChannelResult.builder().channel(NotifyChannel.SMS).status(SKIP).message(用户未绑定手机号).build();}if(!smsSender.isEnabled()){returnNotifySendResult.ChannelResult.builder().channel(NotifyChannel.SMS).status(SKIP).message(短信通道未开启).build();}StringsmsSceneStringUtils.hasText(sceneCfg.getSmsScene())?sceneCfg.getSmsScene():sceneCfg.getScene();smsSender.sendByScene(smsScene,user.getPhone(),params);returnNotifySendResult.ChannelResult.builder().channel(NotifyChannel.SMS).status(SUCCESS).build();}订阅消息的特殊处理模板 ID 占位符微信订阅消息比短信邮件多一道坎模板 ID 得在小程序后台申请申请下来前代码里只能放占位符。我们把模板未配置显式拦下来别让一个假 ID 打出去privateNotifySendResult.ChannelResultsendMpSubscribe(SysNotifyScenesceneCfg,UserNotifyProfileuser,StringtenantId,Mapparams){MpSubscribeSendersendermpSubscribeSenderProvider.getIfAvailable();if(sendernull||!sender.isEnabled()){returnNotifySendResult.ChannelResult.builder().channel(NotifyChannel.MP_SUBSCRIBE).status(SKIP).message(订阅消息通道未启用).build();}if(!StringUtils.hasText(sceneCfg.getMpSubscribeTemplateCode())){returnNotifySendResult.ChannelResult.builder().channel(NotifyChannel.MP_SUBSCRIBE).status(SKIP).message(未配置订阅消息模板).build();}SysMpSubscribeTemplatetplmpSubscribeTemplateService.requireEnabled(tenantId,sceneCfg.getMpSubscribeTemplateCode());if(REPLACE_ME_TMPL_ID.equals(tpl.getTmplId())){returnNotifySendResult.ChannelResult.builder().channel(NotifyChannel.MP_SUBSCRIBE).status(SKIP).message(请先配置真实微信模板 ID).build();}// openid 解析 真正发送...}占位符REPLACE_ME_TMPL_ID是我们的约定任何模板接入前必须先替换这个值上线检查脚本直接 grep 这个字符串发现就阻断发版从流程上杜绝假 ID 上线。踩坑记录短信通道没开静默吞掉了问题现象上线后运营反馈支付成功短信时有时无但站内信一直正常。排查过程翻qkl_notify_send_log发现短信通道状态全是SKIPmessage 是短信通道未开启。但后台明明配了阿里云短信。再看代码sendSms里smsSender.isEnabled()读的配置 key 是短信开关而后台配置页面保存的是另一个 key配置写错地方了。定位思路分发层把通道未开启当成 SKIP 是设计不该发就不发但配置 key 不一致这种低级错误日志里只留 SKIP 很难发现。后来在通道视图里加了配置检查把 smsSender.isEnabled() 读到的值和后台保存的值并排展示。最终解决统一了配置读写 key配置检查面板上线SKIP 原因一眼可见。同时约定任何通道首次 SKIP 超过 24h 要告警防止没配置被当成用户不想收。可复用清单业务方只依赖NotifyFacade.send(userId, scene, params)场景与通道解耦场景表存通道列表 各通道模板编码新场景后台配置即可上线逐通道分发 独立 try/catch单通道故障不影响其他通道每个通道发送前做前置校验返回 SKIP/SUCCESS/FAIL 三种状态方便排查发送日志必落库qkl_notify_send_log是排查时有时无类问题的第一入口配置 key 读写必须同一来源加配置检查面板防低级错误。这套通知编排在 qkl-boot 脚手架里是标准能力。项目源码https://gitee.com/gzqkl/qkl-boot
返回列表