消息模板改版后,旧通知为什么不能变?版本管理、变量校验与内容快照
一张采购申请P701在 11 月 4 日审批通过,申请人U11收到了“申请 P701 已通过。”。第二天,运营把申请结果通知改成“申请 P701 已通过,可在工作台查看处理记录。”。如果用户再打开昨天的通知,页面却显示了新文案,甚至因为今天的模板新增变量而报错,系统就无法回答一个基本问题:昨天实际通知了什么?
这类故障常见于通知表只保存template_key和业务单号,查看或重试时才用“当前模板”重新渲染。本文用虚构的甲公司采购申请继续讨论消息中心,但重点放在可复用的模板治理方法。运行边界为 Java 11 教学示例和 MySQL 8.x 表结构;示例不是任何产品的内部实现。
目录
- 先分清模板定义与一条实际通知
- 模板版本如何发布与回退
- 变量契约如何阻止半截消息
- 通知快照该保存哪些证据
- 用 Java 复现改版、重放和缺变量
- MySQL 约束与验收查询
- 上线边界与纠错策略
- 小结与参考资料
一、先分清模板定义与一条实际通知
模板是生成通知的规则,通知是已经生成的业务记录。两者的生命周期不一样:模板可以迭代,通知一旦进入待投递、已投递或已读流程,其正文就是当时决策的结果。查看旧通知时不应再次选择今天的规则。
以E701为例,申请结果模板APPLY_RESULT/V3的正文为“申请 {{apply_id}} 已{{result}}。”;输入apply_id=P701、result=通过,得到U11的通知。后来发布V4,增加“可在工作台查看处理记录”。若历史页只保存变量与模板键,查询时取最新版本,用户昨天看到的内容就会被改写;如果再次投递时也重算,用户在站内和邮件里还可能看到两种文案。
| 对象 | 何时确定 | 是否允许事后改变 | 用途 |
|---|---|---|---|
| 模板草稿 | 编辑时 | 允许 | 预览和评审,不能正式发送 |
| 已发布模板版本 | 发布时 | 不原地改正文 | 证明某批通知使用了哪套规则 |
| 通知记录 | 事件处理时 | 不重渲染 | 保存实际标题、正文、接收人与事件身份 |
| 通道投递记录 | 发送时 | 状态可推进,内容不漂移 | 记录站内信、邮件各自的尝试和结果 |
图1:P701生成通知时使用 V3;发布 V4 后,历史页仍必须显示当时的正文。
二、模板版本如何发布与回退
一个可操作的发布流程是:在草稿中编辑文案和变量定义,检查所有占位符有声明且必填变量有来源,用样例预览标题、正文与跳转入口,评审通过后创建不可变的发布版本。消息中心选取版本时,将版本号写入通知记录;新版本只影响此后创建的通知。这里的“不变”针对发布内容,停用等治理状态仍可独立记录。
版本号不能仅写在前端配置。若服务节点 A 缓存 V3、节点 B 缓存 V4,却都记为APPLY_RESULT,同一批事件可能生成不同内容。版本选择须有明确的生效点:按创建通知时读取的当前发布版本,或按事件契约指定的业务规则版本;两种都可行,但必须固定并可审计。本文选择前者,因此E701的通知在 V3 生效时创建,E703在 V4 生效后创建。
回退也不意味着把 V4 的数据库行改成 V3 文案。应把“新通知选用的版本”指针切回某个已发布且仍合规的旧版本,并保留 V4 的发布、停用和曾被使用的记录。若某个已发布模板含错字,应发布修正版;已经发出的错通知需要更正时,追加一条有明确关联关系的更正通知,不能静默覆盖历史记录。
图2:草稿可编辑,发布版本冻结;新旧版本并存,历史通知继续引用原版本。
三、变量契约如何阻止半截消息
模板变量看似只是字符串替换,实际是业务数据契约。{{result}}表示审批结果,不是可以空着的装饰字段。若P702的事件缺少result,直接渲染可能得到“申请 P702 已。”或把{{result}}原样发出去。正确的处理点在通知生成前:验证变量名、类型、非空约束和允许取值;失败后持久化错误原因,不生成通知和通道投递意图。
校验不能只看“JSON 对象里有没有键”。空串、null、与业务状态冲突的值都要区分。对于APPROVED事件,结果应来自事件中的可信业务状态映射;不能接受调用方自由传入任意“通过”文案。若模板新增deadline等必填变量,发布前就应检查历史与当前事件契约能否提供它,否则新版本上线会批量阻断通知。JSON Schema 的required用来表达字段存在性;实际非空、枚举和来源规则还需结合业务约束。JSON Schema 官方文档明确区分了属性定义与必填声明。
另一条边界是输出安全。站内文本、HTML 邮件和跳转 URL 的编码规则不同。正文是纯文本时可以做严格占位符替换;若输出为 HTML,要按上下文转义;链接要由受控路由与业务 ID 生成,不允许模板变量直接拼接任意外部网址。本文 Java 例子只处理纯文本,避免用一段简化代码暗示 HTML 和 URL 也可以直接替换。
图3:P701变量齐全才生成通知;P702缺少审批结果时留下错误记录,不投递半截正文。
四、通知快照该保存哪些证据
一条通知至少要能回答:因哪个事件生成、给谁、选了哪个模板版本、生成的标题与正文是什么、何时生成。对E701/U11,保存APPLY_RESULT/V3和“申请 P701 已通过。”;重放事件时先按事件与接收人唯一键找到旧通知,直接返回它,不再读取 V4。发送通道只消费这份快照,这样站内信与邮件有共同内容依据。不同通道确实要使用不同版式时,应分别保存各自实际渲染快照和同一事件关联,而不是把邮件正文从站内信临时改写。
变量快照要克制:存业务编号、结果编码等解释通知所必需的字段;敏感变量能用引用、摘要或有限保留期解决的,不应无限期复制到通知表。即便仅存变量摘要,也无法代替实际渲染正文,因为哈希值不能回答用户看到的字句。模板版本记录提供“当时的规则”,渲染快照提供“当时的结果”,二者互补。
消息生成与投递也要分开。生成失败是变量契约问题,应由业务数据或模板发布流程修复;通道超时是在已有通知快照上重试,不该重新选模板。对错误记录设定重试入口时,必须说明是修复输入后重新生成还是对已生成通知重新投递,两者不能混为一个“重试”按钮。
图4:E701/U11同时绑定事件身份、模板 V3 和渲染正文,历史查看与再次投递共用原记录。
五、用 Java 复现改版、重放和缺变量
下面的 Java 11 示例可以单文件编译运行。它演示发布前核对占位符、禁止覆盖已发布版本、通知生成前严格检查变量、重放返回旧快照。内存 Map 仅为解释状态变化;真正并发部署要由数据库唯一约束、事务和锁或原子写入保证,不能把此示例当生产持久化代码。
importjava.util.*;importjava.util.regex.*;/** 教学模型:模板发布、严格变量校验与通知快照。Java 11。 */publicclassStage11MessageTemplateVersionExample{staticfinalPatternTOKEN=Pattern.compile("\\{\\{([a-z_]+)\\}\\}");staticfinalclassTemplate{finalStringkey;finalintversion;finalStringbody;finalSet<String>required;booleanpublished;Template(Stringkey,intversion,Stringbody,String...required){this.key=key;this.version=version;this.body=body;this.required=newHashSet<>(Arrays.asList(required));}}staticfinalclassNotice{finalStringeventId,recipient,key,body;finalintversion;Notice(StringeventId,Stringrecipient,Templatet,Stringbody){this.eventId=eventId;this.recipient=recipient;this.key=t.key;this.version=t.version;this.body=body;}}staticfinalclassCenter{finalMap<String,Template>templates=newHashMap<>();finalMap<String,Notice>notices=newHashMap<>();finalMap<String,String>errors=newHashMap<>();intactiveVersion;voidpublish(Templatet){Stringid=t.key+"/"+t.version;if(templates.containsKey(id))thrownewIllegalStateException("已发布版本不可覆盖");Matcherm=TOKEN.matcher(t.body);Set<String>found=newHashSet<>();while(m.find())found.add(m.group(1));if(!found.equals(t.required))thrownewIllegalArgumentException("占位符与必填变量不一致");t.published=true;templates.put(id,t);activeVersion=t.version;}Noticecreate(StringeventId,Stringrecipient,Map<String,String>vars){Stringid=eventId+"|"+recipient;Noticeold=notices.get(id);if(old!=null)returnold;// 重放不读取当前模板Templatet=templates.get("APPLY_RESULT/"+activeVersion);if(t==null||!t.published)thrownewIllegalStateException("没有已发布模板");for(Stringname:t.required){if(vars.get(name)==null||vars.get(name).trim().isEmpty()){errors.put(id,"缺少变量:"+name);thrownewIllegalArgumentException("缺少变量:"+name);}}if(!t.required.equals(vars.keySet()))thrownewIllegalArgumentException("存在未声明变量");Matcherm=TOKEN.matcher(t.body);StringBufferout=newStringBuffer();while(m.find())m.appendReplacement(out,Matcher.quoteReplacement(vars.get(m.group(1))));m.appendTail(out);Noticen=newNotice(eventId,recipient,t,out.toString());notices.put(id,n);errors.remove(id);returnn;}}staticintpassed;staticvoidcheck(Stringlabel,booleanok){if(!ok)thrownewAssertionError(label);passed++;System.out.println("PASS "+label);}staticvoidrejects(Stringlabel,Runnableaction){try{action.run();thrownewAssertionError(label);}catch(IllegalArgumentException|IllegalStateExceptionexpected){check(label,true);}}staticMap<String,String>vars(Stringid,Stringresult){Map<String,String>m=newHashMap<>();m.put("apply_id",id);if(result!=null)m.put("result",result);returnm;}publicstaticvoidmain(String[]args){Centerc=newCenter();Templatev3=newTemplate("APPLY_RESULT",3,"申请 {{apply_id}} 已{{result}}。","apply_id","result");c.publish(v3);Noticeold=c.create("E701","U11",vars("P701","通过"));check("P701 使用 V3",old.version==3&&old.body.equals("申请 P701 已通过。"));rejects("发布版本不可覆盖",()->c.publish(newTemplate("APPLY_RESULT",3,"另一个正文 {{apply_id}} {{result}}","apply_id","result")));rejects("模板变量不一致不能发布",()->c.publish(newTemplate("APPLY_RESULT",4,"申请 {{apply_id}} 已完成。","apply_id","result")));Templatev4=newTemplate("APPLY_RESULT",4,"申请 {{apply_id}} 已{{result}},可在工作台查看处理记录。","apply_id","result");c.publish(v4);check("改版不改旧正文",old.body.equals("申请 P701 已通过。")&&old.version==3);Noticereplay=c.create("E701","U11",vars("P701","通过"));check("重放返回原快照",replay==old&&c.notices.size()==1);rejects("缺变量阻断 P702",()->c.create("E702","U11",vars("P702",null)));check("错误可查且无半截通知",c.errors.containsKey("E702|U11")&&!c.notices.containsKey("E702|U11"));Noticefresh=c.create("E703","U11",vars("P703","通过"));check("新通知使用 V4",fresh.version==4&&fresh.body.contains("工作台"));check("通知总数为二",c.notices.size()==2);System.out.println("TOTAL "+passed+"/"+passed+" PASS");}}运行:javac -encoding UTF-8 Stage11MessageTemplateVersionExample.java,然后java Stage11MessageTemplateVersionExample。关键输出应包括:
PASS P701 使用 V3 PASS 发布版本不可覆盖 PASS 模板变量不一致不能发布 PASS 改版不改旧正文 PASS 重放返回原快照 PASS 缺变量阻断 P702 PASS 错误可查且无半截通知 PASS 新通知使用 V4 PASS 通知总数为二 TOTAL 9/9 PASS这段程序刻意没有用通用模板引擎的复杂表达式。模板编辑者若能执行任意表达式或访问任意对象,通知内容就可能越过数据和权限边界。正式系统可选择成熟模板引擎,但应配置变量白名单、模板审查和沙箱能力,并针对实际输出格式做转义。
六、MySQL 约束与验收查询
下面是能说明约束的最小模型,不包含真实系统的审批表和渠道发送表。template_version用(template_key, version_no)唯一键保证发布版本可引用;message_notice用(tenant_id, event_id, recipient_id)拦住重复生成。关系约束和唯一索引的行为以 MySQL 8.4 外键文档为准,部署到 MySQL 8.0 时应在目标版本上重新执行 DDL 验证。
CREATETABLEtemplate_version(template_keyVARCHAR(64)NOTNULL,version_noINTNOTNULL,publish_stateVARCHAR(16)NOTNULL,required_vars JSONNOTNULL,title_textVARCHAR(200)NOTNULL,body_textTEXTNOTNULL,published_atDATETIME(3)NULL,PRIMARYKEY(template_key,version_no))ENGINE=InnoDB;CREATETABLEmessage_notice(notice_idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,event_idVARCHAR(64)NOTNULL,recipient_idVARCHAR(64)NOTNULL,template_keyVARCHAR(64)NOTNULL,version_noINTNOTNULL,rendered_titleVARCHAR(200)NOTNULL,rendered_bodyTEXTNOTNULL,created_atDATETIME(3)NOTNULL,UNIQUEKEYuk_event_recipient(tenant_id,event_id,recipient_id),KEYix_template_ref(template_key,version_no),CONSTRAINTfk_notice_templateFOREIGNKEY(template_key,version_no)REFERENCEStemplate_version(template_key,version_no))ENGINE=InnoDB;CREATETABLEmessage_render_error(tenant_idVARCHAR(32)NOTNULL,event_idVARCHAR(64)NOTNULL,recipient_idVARCHAR(64)NOTNULL,error_codeVARCHAR(40)NOTNULL,missing_varVARCHAR(64)NULL,occurred_atDATETIME(3)NOTNULL,PRIMARYKEY(tenant_id,event_id,recipient_id))ENGINE=InnoDB;写入时在消息中心本地事务内选定发布版本、验证变量、插入通知与投递意图;唯一键冲突后要读取已有通知并核对事件内容,不能盲目覆盖。若模板发布流程与通知生成并发,版本选择必须基于一次明确的已提交版本读取,必要时记录选择时间或发布批次。已发布行的正文不可更新这一规则,靠数据库权限、应用写入入口和审计共同约束;上面的 DDL本身并未阻止 UPDATE。
以P701的通知已落 V3、P703已落 V4、P702缺结果留下错误为前提,验收时执行:
SELECTevent_id,recipient_id,version_no,rendered_bodyFROMmessage_noticeWHEREtenant_id='orgA'ANDevent_idIN('E701','E702','E703')ORDERBYevent_id;-- 预期:E701/U11/V3/申请 P701 已通过。-- E703/U11/V4/申请 P703 已通过,可在工作台查看处理记录。-- 没有 E702 通知SELECTevent_id,error_code,missing_varFROMmessage_render_errorWHEREtenant_id='orgA'ANDevent_id='E702';-- 预期:E702 / MISSING_VARIABLE / resultSELECTtenant_id,event_id,recipient_id,COUNT(*)ASrows_foundFROMmessage_noticeWHEREtenant_id='orgA'ANDevent_id='E701'GROUPBYtenant_id,event_id,recipient_id;-- 预期:U11 的 rows_found=1;重放后仍为 1注意查询的预期值来自前述示例输入,SQL 不是自动造数脚本。实际联调应由正常事件、错误事件和新版本事件分别驱动写入,再执行查询;若只手工插入预期行,无法证明生成流程正确。
图5:旧通知 V3、新通知 V4 与缺变量阻断分别验收;重放旧事件不增加通知。
七、上线边界与纠错策略
上线前应准备至少三组回归数据:旧版本已生成并已投递的通知;新版本生效后生成的新通知;缺失必填变量的阻断事件。先查通知表中的版本与正文,再查实际通道记录是否引用同一份快照,最后在历史页面核对用户看到的文本。对于邮件这类可能被外部服务商再次加工的渠道,应同时保留发送请求摘要、服务商受理号和必要的可审计内容版本;“受理成功”不能写成“用户已读”。
发布检查要覆盖模板变量的兼容性。V4 若增加必填字段,先确定生产事件是否稳定提供;若只有部分业务分支提供,就给出不同模板或把字段声明为可选并定义明确文案,不能让渲染器靠空串蒙混。批量错误出现时可停用 V4 对新通知的选择,但已经生成的 V4 通知仍按其快照处理,错误数据则修复后重新生成。
历史纠错应有明确动作:展示错误通知原文,关联一条更正记录,记录更正原因、操作者和时间。直接修改已读正文虽然短期看起来省事,却会抹掉用户与客服对同一事件的共同证据。若涉及敏感信息或法定删除要求,处理策略应由数据保留与权限制度决定,不能用“模板不可变”作为无限保留个人信息的理由。
八、小结与参考资料
模板管理的关键不是把文案存进表,而是把三个时点固定下来:发布时冻结规则,生成时校验变量并选定版本,投递和回看时使用已生成的内容快照。这样,P701的旧通知在 V4 发布后仍能解释,P702缺字段不会产生半截消息,P703才会使用新文案。
参考资料:
- JSON Schema:对象属性与 required 语义
- MySQL 8.4:外键约束