LikeShop 原生支持的通知渠道并不少:系统通知(站内信)、短信、微信模版消息/小程序订阅消息、APP 推送。但它们的配置入口是分散的,而且每一套都有自己的“外部依赖”。
短信需要先在阿里云或腾讯云申请模版ID,腾讯云要求变量写成{1}、{2} 的连续编号格式,而 LikeShop 系统内配置模版时又要求用${code} 这样的变量名,两个格式之间需要人工对照转换。微信公众号模版消息和小程序订阅消息粗腰在微信公众平台分别申请模版,拿到模版 ID 后面回填到 LikeShop后台;小程序订阅消息的字段名(如 character_string、amount2)必须与微信模版严格——对应,错一个字段就报 47003 错误。站内信虽然没有外部模版申请环节,但内容同样需要在“通知设置”里按场景单独配置。
这意味着,运营想修改一条“订单发货通知”的文案,实际上要面对至少四个配置位置:短信模版内容、公众号模版内容、小程序订阅消息内容、站内信内容。改完一个渠道,另外三个渠道的文案可能已经不一致了;新增一个通知场景,要在四个地方分别配置,漏掉任何一个,用户就可以在某个端收不到通知。
原生后台的“通知设置”页面确实是按场景组织的,每个场景下列出了可用的渠道和模版。但它是“展示层”的统一,不是“数据层”的统一。每个渠道的模版内容仍然是独立的记录,修改一条文案需要在四个独立的编辑框里重复劳动。
二开的核心策略是:不修改原生 NoticeLogic 的发送逻辑,也不覆盖各个渠道的原生模版表,新建一层“统一模版主表 + 渠道映射表”,把“场景 → 渠道 → 模版内容”的关系收拢到一个可统一编辑的数据模型中。发送时仍然调用原生各渠道的发送方法,但模版内容的来源产品“各自独立的配置”改为“从统一模版层按场景和渠道解析”。
2.1 为什么不能把所有渠道的模版塞进一张“万能表”
最直觉的方案是建一张大表,字段包括 scene、sms_contact、wechat_contact、miniapp_template_id、inbox_content。这个方案在场景数量少的时候能用,但它把渠道的差异硬编码进了表结构:新增一个渠道(比如企业微信通知)就要加字段,渠道的模版变量数量不同、格式不同,字段会迅速膨胀成一张难以维护的宽表。
正确的模型是场景 + 渠道 + 渠道模版三层分离。场景是业务事件的抽象(“订单支付成功”“订单发货”“退款完成”),渠道是发送通道(短信、公众号、小程序、站内信),渠道模版是某个场景在某个渠道上的具体配置(内容、变量映射、第三方模版ID、启用状态)。
2.2 场景定义表
场景是统一管理的“根”,所有渠道模版都挂在场景下:
sql
CREATE TABLE `ls_notice_scene` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`scene_code` varchar(50) NOT NULL COMMENT '场景编码,如 order_paid / order_shipped',
`scene_name` varchar(50) NOT NULL COMMENT '场景名称',
`scene_group` varchar(30) NOT NULL DEFAULT 'order' COMMENT '场景分组:order/after_sale/marketing/member',
`default_channels` varchar(100) NOT NULL DEFAULT '' COMMENT '默认启用渠道,逗号分隔',
`is_enable` tinyint NOT NULL DEFAULT 1,
`sort` int NOT NULL DEFAULT 0,
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_scene_code` (`scene_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知场景定义表';
scene_code 与原生 NoticeEnum 中的场景标识对齐,不新建一套场景命名。场景分组用于后台按业务域折叠展示,避免场景数量增长后列表过长。
2.3 渠道模版表
这是统一管理的核心表,每个场景在每个渠道上有一条记录。渠道的差异化字段全部收进 JSON 配置,而不是给每个渠道加列:
sql
CREATE TABLE `ls_notice_template` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`scene_id` int UNSIGNED NOT NULL COMMENT '场景ID',
`channel` varchar(20) NOT NULL COMMENT '渠道:sms/oa_wechat/miniapp/inbox/app_push',
`template_title` varchar(100) NOT NULL DEFAULT '' COMMENT '模板标题(站内信/公众号标题)',
`template_content` text COMMENT '统一的内容模板,变量用 ${var} 格式',
`third_party_id` varchar(100) NOT NULL DEFAULT '' COMMENT '第三方模板ID(短信模板ID/微信模板ID)',
`variable_map` text COMMENT '变量映射,JSON格式:统一变量 → 渠道字段名',
`channel_config` text COMMENT '渠道特有配置,JSON格式',
`is_enable` tinyint NOT NULL DEFAULT 1,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_scene_channel` (`scene_id`, `channel`),
KEY `idx_channel_enable` (`channel`, `is_enable`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一渠道模板表';
**template_content 使用统一的 {member_name}、${order_sn} 这样的语义化变量,而不是腾讯云的 {1}、{2} 或者小程序的 character_string1。
variable_map 负责把统一变量翻译成各渠道的字段名。例如:
json
{
"member_name": "character_string1",
"order_sn": "character_string2",
"goods_name": "thing1"
}
这是小程序订阅消息能发出去的关键。微信订阅消息的字段名由模版决定,variable_map 让统一模版中的语义变量在发送时被替换为正确的 character_string1 / thing1 等字段名,避免硬编码在发送逻辑中。
third_party_id 统一存放各渠道的外部模版标识:短信的阿里云/腾讯云模版ID、公众号模版消息的 template_id、小程序订阅消息的 tmplId。这些 ID 仍然需要在各自平台申请,但申请完成后只需回填到这一张表,而不是在多个配置页面分别维护。
channel_config 存放渠道特有的非模版配置,例如小程序的 page 跳转路径、公众号的 url、短信的签名。这些内容不适合塞进 template_content,但又是发送时必须的。
2.4 变量定义表
统一变量不能是“随便写”的,否则运营在模版里写 ${foo},发送时没有对应的业务数据,消息会带空字符串或直接失败。变量定义表约束每个场景可用的变量集合:
sql
CREATE TABLE `ls_notice_variable` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`scene_id` int UNSIGNED NOT NULL COMMENT '所属场景',
`var_key` varchar(50) NOT NULL COMMENT '变量名,如 order_sn',
`var_label` varchar(50) NOT NULL COMMENT '展示名称,如 订单编号',
`var_type` varchar(20) NOT NULL DEFAULT 'string' COMMENT '类型:string/number/amount/date',
`is_required` tinyint NOT NULL DEFAULT 0 COMMENT '是否必填',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_scene_var` (`scene_id`, `var_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板变量定义表';
后台编辑模版时,变量以“可点击插入”的标签形式展示,而不是让运营手动输入 ${...}。is_required 的变量在模版保存时做占位符存在性校验——必填变量没有出现在模版中时不允许保存。
3.1 “同步”不是推送,是统一数据源
“修改后全端同步生效”听起来像是把内容推送到四个不同的系统,但实际工程上不需要。统一模版表就是唯一的数据源,各渠道的发送逻辑在运行时从这张表读取模版内容,而不是从各自的原生配置表读取。修改统一模版表中的一条记录,下一次发送自然就会使用新内容,不存在“推送同步”的环节。
真正需要“同步”的是渠道的启用/停用状态。一个场景在哪些渠道上生效,由场景表的 default_channels 和渠道模版表的 is_enable 共同决定。运营在后台切换某个场景的“短信通道”开关,实际上只是把 ls_notice_template 中对应记录的 is_enable 置为 0,发送路由层据此跳过短信通道。
3.2 与原生模版表的关系:不覆盖,只映射
原生各渠道的模版配置仍然存在,二开不删除、不修改它们。统一模版层是“新增的读取层”,发送时优先从统一模版表解析;如果某个场景在统一模版表中没有配置,则降级回原生的模版配置。这样做的原因是:存量项目的通知配置不需要一次性迁移,二开上线后,已经配置好的场景继续按原生方式工作,只有运营主动在统一后台编辑过的场景才切换到统一模版层。
ls_notice_template 与原生模版表的关联通过 scene_code + channel 对齐,不需要在原生表中加外键字段。
4.1 发送链路的改造点
原生通知的发送入口是 NoticeLogic,按场景调用对应的渠道发送方法。二开不修改 NoticeLogic 的发送方法体,而是在它之前插入一层 UnifiedNoticeRouter,负责三件事:判断该场景有哪些启用的渠道、从统一模版表渲染出各渠道的最终内容、逐个渠道调用原生发送方法。
php
public function dispatch($sceneCode, $variables, $user)
{
$scene = NoticeSceneModel::where('scene_code', $sceneCode)
->where('is_enable', 1)->find();
if (!$scene) return;
$templates = NoticeTemplateModel::where('scene_id', $scene['id'])
->where('is_enable', 1)->select();
foreach ($templates as $tpl) {
// 变量完整性校验
if (!$this->validateVariables($tpl, $variables)) {
$this->logSkip($tpl, '变量缺失');
continue;
}
$rendered = $this->render($tpl, $variables);
switch ($tpl['channel']) {
case 'sms':
SmsService::send($user['mobile'], $rendered['content'], $tpl['third_party_id']);
break;
case 'miniapp':
MiniappSubscribeService::send(
$user['miniapp_openid'],
$tpl['third_party_id'],
$rendered['mapped_data'],
$this->getPage($tpl, $variables)
);
break;
case 'oa_wechat':
OaTemplateService::send(
$user['oa_openid'], $tpl['third_party_id'],
$rendered['mapped_data'], $rendered['url'] ?? ''
);
break;
case 'inbox':
InboxService::create($user['id'], $rendered['title'], $rendered['content']);
break;
}
}
}
4.2 变量渲染的两阶段:语义替换 + 渠道映射
渲染不是简单的 str_replace。第一阶段用业务数据替换统一变量,得到一份“语义完整”的内容;第二阶段按 variable_map 把语义变量转换为渠道字段名,供小程序订阅消息这类结构化接口使用:
php
private function render($tpl, $variables)
{
// 阶段一:统一变量替换
$content = $tpl['template_content'];
foreach ($variables as $key => $value) {
$content = str_replace('${' . $key . '}', $value, $content);
}
// 阶段二:渠道字段映射(仅结构化渠道需要)
$mappedData = [];
$map = json_decode($tpl['variable_map'], true) ?: [];
foreach ($map as $unifiedKey => $channelField) {
$mappedData[$channelField] = $variables[$unifiedKey] ?? '';
}
return [
'title' => $tpl['template_title'],
'content' => $content,
'mapped_data' => $mappedData,
];
}
渲染必须在发送时执行,不能在配置保存时预渲染。业务变量(订单号、商品名、金额)是随每一笔通知变化的,模版中保留的是占位符,不是最终文案。
4.3 渠道降级与兜底
统一路由层天然适合做渠道降级。一个典型策略是:小程序订阅消息发送失败时,自动降级到短信。订阅消息依赖用户授权,且一次性订单只能发一条,发送失败率不低。路由层在调用小程序发送后检查返回结果,失败则触发短信通道的兜底发送:
php
$result = MiniappSubscribeService::send(...);
if (!$result['success'] && $this->hasChannel($scene, 'sms_fallback')) {
SmsService::send($user['mobile'], ...);
}
这个降级逻辑放在路由层,而不是塞进原生 NoticeLogic,符合 LikeShop 二开的“扩展优先”原则。
5.1 场景 x 渠道的矩阵视图
后台不需要为每个渠道做一个独立菜单。统一管理页面的主视图是一个场景列表,每个场景展开后是一个“渠道卡片”矩阵:短信、公众号、小程序、站内信四张卡片并排,每张卡片显示该渠道的启用状态、模版内容摘要、第三方模版ID 是否已回填。运营在一个页面内就能看到某个场景在哪些渠道上可用、哪些渠道还缺配置。
点击卡片进入编辑弹层,编辑的是统一模版内容。变量以标签形式插入,保存时校验必填变量是否完整。
5.2 一键启用/停用渠道
场景列表的每一行提供渠道开关的下拉面板,勾选/取消勾选即切换该场景在对应渠道上的 is_enable。这个动作不修改原生各渠道的配置表,只影响统一路由层是否将该渠道纳入发送范围。
5.3 配置完整度检查
后台提供一个“配置完整度”视图,按场景列出四个渠道的配置状态:已配置且启用、已配置但停用、未配置。未配置的渠道如果该场景需要发送,会以警示色标出,提醒运营补全。这比“逐个渠道菜单翻找”更容易发现遗漏。
6.1 后台配置清单
| 配置项 | 类型 | 说明 |
|---|---|---|
| 统一通知开关 | 开关 | 关闭后所有场景回退到原生各渠道配置 |
| 场景列表 | 只读+排序 | 场景编码与原生 NoticeEnum 对齐,不新增场景 |
| 渠道模板编辑 | 按场景×渠道 | 统一变量语法的模板内容、第三方模板 ID、变量映射 |
| 变量定义 | 按场景维护 | 可用变量集合,必填变量校验 |
| 渠道启用矩阵 | 场景×渠道开关 | 控制统一路由层是否发送该渠道 |
| 降级兜底开关 | 按场景配置 | 小程序发送失败时是否降级短信 |
| 发送日志 | 查询列表 | 按场景、渠道、用户、时间检索发送结果和失败原因 |
6.2 开发注意事项
- 统一模版层是“新增读取层”,不是“替换原生模版表”。原生各渠道的模版配置保留不动,统一路由层在运行时按场景和渠道决定从统一表读取还是降级回原生配置。这样存量项目的通知配置不需要迁移,二开上线后未编辑过的场景行为不变。
- 小程序订阅消息的字段映射是必须解决的硬约束。微信要求 character_string1、thing1 等字段名与模版严格对应,统一模版中的{order_sn} 当作字段名传给微信接口。
- 变量缺失必须跳过发送并记录日志,不能发送空值。一条短信里写着“您的订单 已发货”,比没有发这条短信更伤用户体验。路由层在渲染前校验必填变量,缺失时跳过该渠道并写入发送日志,由运营在后台补全数据或调整模版。
- 第三方模版 ID 仍然需要在各平台单独申请,统一管理不能替代平台侧审核。二开统一的是“配置位置和内容语法”,不是“模版审核流程”。短信模版、微信模版的申请仍然在各平台完成,third_party_id 只是回填后的引用。
- 兼容性。新增的场景表、模版表、变量表独立于原生 NoticeLogic 和各渠道的配置表。统一通知开关关闭时,所有通知走原生 NoticeLogic 的发送链路,行为与二开前完全一致。统一路由层作为 NoticeLogic 之前的扩展层接入,不修改原生发送方法的方法体,保证官方升级时核心文件可以干净合并。
