<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>changeloop blog</title><description>发布说明实践，以及作为构建产物的 changelog。</description><link>https://changeloop.dev/</link><language>zh-CN</language><item><title>缺陷修复发布说明：怎样写出读者真正会用的条目</title><link>https://changeloop.dev/blog/zh/bug-fix-release-notes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/bug-fix-release-notes/</guid><description>缺陷修复发布说明想有用，每条都要写清症状、受影响的人群、起始时间和读者下一步该做什么。本文提供改写对照，并讲解安全修复、回归和数据丢失的写法。</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;好的缺陷修复发布说明，描述的是用户看到了什么出错，而不是代码哪里出了错。每一条都要说明谁受到了影响、从什么时候开始、修复是否彻底，以及读者是否需要做什么，哪怕只是一句&amp;quot;无需任何操作&amp;quot;。&lt;/p&gt;
&lt;p&gt;大多数团队都是直接从提交信息里抄一行。下面的表格给出六个改写示例，表格之后的各节则解释其中的规则。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;修改前（提交信息）&lt;/th&gt;
&lt;th&gt;修改后（症状）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Fixed null pointer in export handler&lt;/td&gt;
&lt;td&gt;当项目没有标签时，导出不再失败并提示&amp;quot;出错了&amp;quot;。请重新运行自 9 月 3 日以来失败的所有导出。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved race condition in sync worker&lt;/td&gt;
&lt;td&gt;在几秒钟内于两台设备上所做的编辑，不再互相覆盖。无需任何操作。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix timezone bug&lt;/td&gt;
&lt;td&gt;定时报表现在会在你设定的时间运行。自 8 月 12 日以来，UTC 以东的账户看到的报表最多提前了一天。无需更改任何设置。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched XSS in comment renderer&lt;/td&gt;
&lt;td&gt;安全修复：一条精心构造的评论可能在其他用户的浏览器中运行脚本。请今天就升级到 4.2.1。我们在日志中没有发现被利用的迹象。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed regression from 4.1.0&lt;/td&gt;
&lt;td&gt;包含连字符的查询又可以搜索了。该问题出现在 4.1.0，已在 4.1.1 中修复。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug fixes and performance improvements&lt;/td&gt;
&lt;td&gt;请说明具体是哪些。见最后一节。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;如何在发布说明里写一条缺陷修复？&lt;/h2&gt;
&lt;p&gt;先用用户自己的话写症状，再写谁受到影响、从什么时候开始，然后写修复的状态，最后写要采取的行动。通常一两句话就够了。代码层面的原因应该写在 pull request 里，工程师会去那里找。&lt;/p&gt;
&lt;p&gt;读者扫读时只找一件事：&amp;quot;这是我遇到的吗？&amp;quot;四个部分几乎涵盖了所有条目：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;**症状。**屏幕上、API 响应里或者发票上出现了什么。如果有错误文字，就原样引用，因为人们会去搜索它。&lt;/li&gt;
&lt;li&gt;**范围。**哪个套餐、平台、API 版本或数据形态。&amp;quot;行数超过 50,000 的账户&amp;quot;是可以核对的，&amp;quot;部分用户&amp;quot;则不行。&lt;/li&gt;
&lt;li&gt;**时间窗口。**从哪个版本或哪个日期开始，这样读者就能判断昨天那个奇怪的结果是不是这个缺陷造成的。&lt;/li&gt;
&lt;li&gt;**行动。**重新运行、重新同步、升级、移除变通方案，或者什么也不用做。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果用户已经搭建了变通方案，行动那一行就是告诉他们可以删掉它的地方。&lt;/p&gt;
&lt;h2&gt;发布说明和体验日志有什么区别？&lt;/h2&gt;
&lt;p&gt;体验日志是完整的、持续更新的变更记录。发布说明则是针对一次发布、经过挑选和改写的消息，写给那些要判断是否值得关心的人。就缺陷修复而言，体验日志列出每一项修复，而发布说明则以读者可能注意到的那些修复为主。&lt;/p&gt;
&lt;p&gt;工具提示里的一个错别字，只需要写进体验日志。发票上的税率错误，则两边都要写。完整的区分见&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明的区别&lt;/a&gt;，一套好的发布说明应有的样子见&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;如何写出用户真正愿意花时间读完的发布说明&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://keepachangelog.com/en/1.1.0/&quot;&gt;Keep a Changelog&lt;/a&gt; 是记录这一侧一个很方便的约定。它把&amp;quot;Fixed&amp;quot;留给所有缺陷修复，并为漏洞单独设置一个&amp;quot;Security&amp;quot;标题，这与本文面向读者所做的划分是一致的。&lt;/p&gt;
&lt;h2&gt;缺陷修复算更新吗？&lt;/h2&gt;
&lt;p&gt;算。缺陷修复改变了产品，所以发布它就是一次更新。按照&lt;a href=&quot;https://semver.org/&quot;&gt;语义化版本&lt;/a&gt;，向后兼容的修复属于补丁版本，例如从 4.2.0 到 4.2.1。&lt;/p&gt;
&lt;p&gt;读者是否需要做什么，是另一个问题，说明里应该回答它。一个改变了正确调用方所观察到的结果的修复，已经接近破坏性变更，&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;破坏性变更&lt;/a&gt;一文解释了这条界线在哪里。&lt;/p&gt;
&lt;h2&gt;什么时候一项修复该有独立条目，什么时候算小修复？&lt;/h2&gt;
&lt;p&gt;当用户可能注意到了这个缺陷、因它损失了时间或数据，或者围绕它搭建了变通方案时，就给这项修复一个独立条目。当团队之外没有人可能看到它时，就把它归入一个简短的&amp;quot;小修复&amp;quot;列表。判断的依据是读者的体验，而不是改动的大小。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;有独立条目&lt;/th&gt;
&lt;th&gt;放进小修复列表&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;由客户报告，或者很多人遇到&lt;/td&gt;
&lt;td&gt;一个很少被打开的界面里的外观小毛病&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;导致输出错误、任务失败或工作丢失&lt;/td&gt;
&lt;td&gt;错别字、间距、一个没对齐的图标&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;需要读者采取行动&lt;/td&gt;
&lt;td&gt;内部工具或管理页面中的修复&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;近期某个版本带来的回归&lt;/td&gt;
&lt;td&gt;只在测试环境中出现的故障&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;涉及账单、权限或数据&lt;/td&gt;
&lt;td&gt;日志措辞，以及对用户没有影响的依赖升级&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;分组里的每一行仍然要说点什么：&amp;quot;修复了一些界面问题&amp;quot;只是一个占位符。&lt;/p&gt;
&lt;h2&gt;如何撰写关于回归的说明？&lt;/h2&gt;
&lt;p&gt;点明引入它的那个版本，称它为回归，并给出修复它的版本。遇到这个缺陷的人本来就知道它坏了，所以简短而直接的承认，比含糊的措辞对他们更有帮助。&lt;/p&gt;
&lt;p&gt;例如：&amp;quot;包含连字符的查询，在 4.1.0 中返回的搜索结果为空。该问题已在 4.1.1 中修复。如果你为了避开连字符而改过查询，现在可以改回来了。&amp;quot;&lt;/p&gt;
&lt;p&gt;对于因这个缺陷浪费了一下午的人来说，&amp;quot;提升了搜索的可靠性&amp;quot;读起来像是在回避。如果原因仍在确认中，就如实说明，正如&lt;a href=&quot;https://changeloop.dev/blog/zh/emergency-release-notes/&quot;&gt;紧急发布说明&lt;/a&gt;的指导所说：说明的口气，永远不要比团队实际掌握的更肯定。&lt;/p&gt;
&lt;h2&gt;如何公布一项安全修复？&lt;/h2&gt;
&lt;p&gt;要平实地说明严重程度，写出受影响的版本以及修复它们的版本，说明升级有多紧迫，如果存在 CVE 编号，也要写上。只有当用户能够据此采取行动时才公布细节，如果有报告者参与，就遵循协调披露流程。&lt;/p&gt;
&lt;p&gt;顺序很重要：报告者私下告知你，你发布修复，等用户可以保护自己时再发布公开说明。&lt;a href=&quot;https://www.cisa.gov/coordinated-vulnerability-disclosure-process&quot;&gt;CISA&amp;#39;s coordinated vulnerability disclosure process&lt;/a&gt; 负责协调漏洞的报告、分析和公开披露。&lt;a href=&quot;https://www.cve.org/ResourcesSupport/AllResources/CNARules&quot;&gt;CVE Numbering Authority rules&lt;/a&gt; 规定了 CVE 记录如何分配和发布，而在 GitHub 上，&lt;a href=&quot;https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-repository-security-advisories&quot;&gt;repository security advisory&lt;/a&gt; 让你可以私下起草公告并申请编号。&lt;/p&gt;
&lt;p&gt;一条安全条目通常包含四项事实：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;攻击者能做什么，用一句话说明，不附带概念验证。&lt;/li&gt;
&lt;li&gt;受影响的版本，以及修复它的版本。&lt;/li&gt;
&lt;li&gt;有多紧迫：&amp;quot;今天就升级&amp;quot;还是&amp;quot;在下一次发布时升级&amp;quot;。&lt;/li&gt;
&lt;li&gt;你是否看到了被利用的迹象，如果报告者同意，还要致谢。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要写出利用的步骤。&lt;/p&gt;
&lt;h2&gt;数据丢失的修复，说明里应该写什么？&lt;/h2&gt;
&lt;p&gt;说明哪些数据受到了影响，如何判断自己的数据是否受影响，以及能否恢复。在这里&amp;quot;无需任何操作&amp;quot;很少是真的，而读者的第一个问题是&amp;quot;我的数据是不是没了&amp;quot;。&lt;/p&gt;
&lt;p&gt;一条可用的条目，要给出丢失数据的条件（&amp;quot;在同步运行期间删除文件夹&amp;quot;）、可能发生的时间窗口、检查的办法（&amp;quot;打开回收站，查找日期在 9 月 3 日到 9 日之间的项目&amp;quot;），以及恢复的途径。如果数据无法恢复，就如实说明。也要直接联系受影响的客户，因为发布说明不应该是某个人得知自己的数据受到影响的唯一地方。&lt;/p&gt;
&lt;h2&gt;为什么&amp;quot;错误修复与性能改进&amp;quot;是一条糟糕的说明？&lt;/h2&gt;
&lt;p&gt;它没有给读者任何可以采取行动的东西，还把有人一直在等的修复藏了起来。报告过崩溃的客户，无法判断它是否已经修好，而有变通方案的客户，也无法判断是否该移除它。&lt;/p&gt;
&lt;p&gt;有两种坦诚的替代做法。如果一次发布里没有任何读者能注意到的内容，就不要为它发布说明，让体验日志保存记录。如果有修复，就用读者的话把它们列出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;修改前：
  错误修复与性能改进。

修改后：
  已修复：没有标签的项目无法导出 CSV。
  已修复：深色模式下评论框里的光标不可见。
  更快：包含超过 100 个项目的工作区，
  仪表盘打开得更快了。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;缺陷修复说明从哪里来？&lt;/h2&gt;
&lt;p&gt;它们来自修复这个缺陷的 pull request，以及触发它的那份报告。如果报告者的原话随着修复一起流转，症状就已经写好了一半。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-vs-bug-report/&quot;&gt;功能请求还是缺陷&lt;/a&gt;解释了为什么正确地给报告打上标签，决定了由谁来负责它。在 Changeloop 中，通过小组件提交的缺陷会变成一个带有 &lt;code&gt;bug&lt;/code&gt; 标签的 GitHub issue，体验日志条目则从已合并的 pull request 起草，并留给人批准后再发布。手写时，&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;给你同样的条目结构：症状、范围、时间窗口、行动。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;缺陷修复发布说明应该包含什么?&lt;/strong&gt;
每一条都应该写明用户看到的症状、谁受到了影响、从哪个版本或哪个日期开始、修复是否彻底，以及读者需要做什么，包括&amp;quot;什么也不用做&amp;quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每一项缺陷修复都应该列在发布说明里吗?&lt;/strong&gt;
不需要。列出那些用户可能注意到的、让他们损失过时间的或者他们绕开过的修复，并把外观上的或内部的修复归入一个简短的&amp;quot;小修复&amp;quot;列表。体验日志会保留每一项修复，供需要查找的人使用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果缺陷是自己引入的，发布说明该怎么写?&lt;/strong&gt;
说明它是一次回归，点明引入它的版本和修复它的版本，并告诉读者是否可以移除任何变通方案。平实的陈述比委婉的措辞读起来更好。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如何查看自己所用产品的发布说明?&lt;/strong&gt;
在产品的帮助菜单、页脚或文档里找体验日志或发布说明页面的链接，开源项目则可以看仓库的 releases 标签页。&lt;/p&gt;
</content:encoded></item><item><title>如何在软件产品中向客户征集反馈：时机、渠道与话术</title><link>https://changeloop.dev/blog/zh/how-to-ask-for-customer-feedback/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/how-to-ask-for-customer-feedback/</guid><description>想在软件产品里收到有用的反馈，就要在用户刚完成某个操作后，在他们工作的地方，只问一个具体的问题。本文按时机和渠道给出现成话术，并说明拿到答案后如何回复。</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;要在软件产品中向客户征集反馈，就针对用户刚刚做过的某件事，在他们做这件事的地方，问一个具体的问题。刚导出完报表就问&amp;quot;刚才那次导出满足你的需求了吗？&amp;quot;，会得到回答；在页脚里写一句&amp;quot;告诉我们你对产品的看法&amp;quot;，只会得到沉默。本页接下来讲的，就是时机、渠道和确切的措辞。&lt;/p&gt;
&lt;p&gt;关于这个话题的大多数建议，都是写给商店和客服台的。软件团队清楚地知道用户一秒钟之前做了什么，所以问题完全可以围绕那件事来问。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;时机&lt;/th&gt;
&lt;th&gt;在哪里问&lt;/th&gt;
&lt;th&gt;现成的问法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;一项任务刚完成之后&lt;/td&gt;
&lt;td&gt;应用内，紧挨着结果&lt;/td&gt;
&lt;td&gt;&amp;quot;刚才那次导出满足你的需求了吗？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;首次使用新功能之后&lt;/td&gt;
&lt;td&gt;应用内，只问一次&lt;/td&gt;
&lt;td&gt;&amp;quot;你想用批量编辑完成什么事情？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;支持工单解决之后&lt;/td&gt;
&lt;td&gt;在支持对话里&lt;/td&gt;
&lt;td&gt;&amp;quot;问题解决了吗，还是仍然有哪里不对？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户中途停下或离开某个流程之后&lt;/td&gt;
&lt;td&gt;一天后发邮件&lt;/td&gt;
&lt;td&gt;&amp;quot;你停在了设置的第 3 步，是什么挡住了你？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;经常使用 30 天之后&lt;/td&gt;
&lt;td&gt;由一个具名的人发邮件&lt;/td&gt;
&lt;td&gt;&amp;quot;你最想改变的那一件事是什么？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户取消订阅时&lt;/td&gt;
&lt;td&gt;在取消流程里&lt;/td&gt;
&lt;td&gt;&amp;quot;是什么让你今天决定离开？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;你交付了他们提过的需求之后&lt;/td&gt;
&lt;td&gt;在他们提出请求的地方&lt;/td&gt;
&lt;td&gt;&amp;quot;你提过 CSV 导入，现在上线了。它能覆盖你的情况吗？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;什么时候向用户征集反馈最合适？&lt;/h2&gt;
&lt;p&gt;最合适的时机，是用户刚完成某件事、细节还留在脑子里的时候。紧跟在一个操作之后的问题，会得到关于这个操作的回答。而凭空冒出来的问题，得到的要么是对方当下心情的回答，要么根本没有回答。&lt;/p&gt;
&lt;p&gt;不要在注册时问，因为那时还没有人用过任何东西。不要在任务进行到一半时问，因为你打断的正是你想了解的那件事。一旦对方回答过了，在你有进展可以回复之前，就不要再去打扰他。&lt;/p&gt;
&lt;h2&gt;应该在哪里向客户征集反馈？&lt;/h2&gt;
&lt;p&gt;在体验发生的地方问。应用内的提示适合问关于某个界面的问题。支持对话适合问关于某次修复的问题。邮件适合问关于一周使用情况的问题，或者关于对方中途放弃的某个流程。通话适合问那些你无法预测的问题。&lt;/p&gt;
&lt;p&gt;每个渠道得到的回答类型都不一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;**应用内：**简短、即时、具体，但只来自当下在场的人。已经离开的用户，你什么也听不到。&lt;/li&gt;
&lt;li&gt;**支持对话：**来自那些已经沮丧到愿意写信进来的人。适合找出坏掉的东西，不适合评判产品的其余部分。&lt;/li&gt;
&lt;li&gt;**邮件：**来自更少的人、篇幅更长的回答，也是联系那些已经沉默的用户的唯一办法。把它写成一位具名的人发出的简短留言，里面只放一个问题。&lt;/li&gt;
&lt;li&gt;**访谈：**了解人们为什么这样做事的办法。请他们演示自己的工作方式，并在他们操作时保持安静。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href=&quot;https://changeloop.dev/blog/zh/feedback-signal-quality/&quot;&gt;反馈信号质量&lt;/a&gt;介绍了如何衡量每个渠道告诉你的内容。&lt;/p&gt;
&lt;h2&gt;怎样才算专业地征集反馈？&lt;/h2&gt;
&lt;p&gt;针对具体的事，说明你为什么问，并且让回答的成本不到一分钟。专业的问法会点明那个时刻，让对方明白会有真人阅读答案，并且不会为打扰而道歉。&lt;/p&gt;
&lt;p&gt;点明确切的操作（&amp;quot;你刚刚运行的那次导出&amp;quot;），只问一件事，使用没有必填项的自由文本框，并以名字签名。&lt;/p&gt;
&lt;h2&gt;征集反馈时用什么样的句子比较好？&lt;/h2&gt;
&lt;p&gt;好的句子，是针对一个具体时刻、几个字就能回答的问题。对比下面两列。左边的可以耸耸肩就回答，右边的则需要对方回想起一些真实的事情。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;较弱的问法&lt;/th&gt;
&lt;th&gt;更有力的问法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&amp;quot;有什么反馈吗？&amp;quot;&lt;/td&gt;
&lt;td&gt;&amp;quot;设置这个功能时最难的是哪一步？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;你觉得我们的产品怎么样？&amp;quot;&lt;/td&gt;
&lt;td&gt;&amp;quot;上周你用它做了什么？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;请为你的体验打 1 到 10 分。&amp;quot;&lt;/td&gt;
&lt;td&gt;&amp;quot;你今天想做的事做完了吗？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;告诉我们该如何改进。&amp;quot;&lt;/td&gt;
&lt;td&gt;&amp;quot;这周有什么事拖慢了你？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;你会推荐我们吗？&amp;quot;&lt;/td&gt;
&lt;td&gt;&amp;quot;你最近一次把它展示给了谁，你是怎么说的？&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;另一个几乎在任何地方都管用的问题：&amp;quot;当这个功能对你不管用时，你会改用什么？&amp;quot;它会让真正的竞争对手浮出水面，而那往往是一张电子表格。&lt;/p&gt;
&lt;h2&gt;最糟糕的征集反馈方式有哪些？&lt;/h2&gt;
&lt;p&gt;最糟糕的问法，是宽泛的、过早的、冗长的或者带有引导的。它们有一个共同的问题：对方要回答，就得先替你做完你本该做的思考。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;**&amp;quot;请填写我们的 20 道题调查问卷。&amp;quot;**能填完的人，是空闲时间最多或者意见最强烈的人。&lt;/li&gt;
&lt;li&gt;**登录后第一个页面上的弹窗。**用户是来做事的，而你挡住了他。关掉它是唯一合理的回答。&lt;/li&gt;
&lt;li&gt;**&amp;quot;我们很期待你的反馈！&amp;quot;却没有任何问题。**这是在要求用户自己去发明话题。&lt;/li&gt;
&lt;li&gt;**带引导的问题：&amp;quot;你有多喜欢新的仪表盘？&amp;quot;**你得到的是附和，什么也学不到。&lt;/li&gt;
&lt;li&gt;**只有评分，没有追问。**10 分里的 6 分只能告诉你情绪，不能告诉你该改什么。&lt;/li&gt;
&lt;li&gt;**问完之后就没了下文。**这会让你失去下一轮，下面会讲到。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;关于产品的客户反馈叫什么？&lt;/h2&gt;
&lt;p&gt;关于产品的反馈通常称为产品反馈，可以分成两类。缺陷报告说的是某个东西没有按预期工作。功能请求说的是缺少了某个东西。这个区分决定了谁先来看它，而&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-vs-bug-report/&quot;&gt;功能请求还是缺陷&lt;/a&gt;划清了这条界线。第三类是赞扬，值得保存下来，并在征得许可后引用。&lt;/p&gt;
&lt;p&gt;一个把&amp;quot;缺陷&amp;quot;和&amp;quot;功能请求&amp;quot;作为第一个选项的反馈表单，已经替你完成了这第一次分类。&lt;/p&gt;
&lt;h2&gt;拿到答案之后怎么办？&lt;/h2&gt;
&lt;p&gt;把每一条答案放到团队本来就在工作的地方，并保留对方的原话。一行引用的原文，胜过你对它的概括。按类型和大致的紧急程度打上标签，合并重复项，然后做出决定：构建、搁置，还是拒绝。&lt;/p&gt;
&lt;p&gt;拒绝同样算一种回答。&amp;quot;我们不会构建这个，原因如下&amp;quot;，能结束对方的等待，而&lt;a href=&quot;https://changeloop.dev/blog/zh/declining-feature-requests/&quot;&gt;拒绝功能请求&lt;/a&gt;里有相应的措辞。至于背后的管道，&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;功能请求追踪&lt;/a&gt;介绍了如何把来自五个渠道的请求汇入同一份清单。如果你以书面形式接收请求，&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-template/&quot;&gt;功能请求模板&lt;/a&gt;能让它们保持可比较。&lt;/p&gt;
&lt;p&gt;Changeloop 的小组件会把每次提交作为一个 GitHub issue 归档，所以反馈会落在将要修复它的代码旁边。无论使用哪种工具，规则都是一样的：一份清单，一个负责人，没有任何一条答案被遗落在某个人的收件箱里。&lt;/p&gt;
&lt;h2&gt;为什么要告诉大家发布了什么？&lt;/h2&gt;
&lt;p&gt;它让对方看到，回答是值得花时间的。一个告诉过你某件事、后来听到&amp;quot;这个已经发布了，谢谢你&amp;quot;的用户，就有理由再次回答。一个什么也没有听到的用户，会断定这个框从来没人读。&lt;/p&gt;
&lt;p&gt;所以征集的最后一步是回复。当每位提出请求的人所要的东西上线时，用他们自己的话、在他们使用过的渠道上告诉他们。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭合客户反馈循环&lt;/a&gt;介绍了这套机制：已发布的体验日志条目是触发消息的条件，所以请求者只有在变更真正上线之后才会被告知。在 Changeloop 中，如果小组件里的反馈变成了一个 GitHub issue，而合并的 pull request 又关闭了它，那么批准条目就会在那个 issue 上发出一条&amp;quot;Shipped&amp;quot;评论，并在小组件中向提交者展示这条条目；手动创建的 issue，以及 GitLab 或 Bitbucket 仓库，不会收到评论。我们的文档列出了&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;小组件和信息流的设置&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;回复可以很简短：&amp;quot;你在三月提过 CSV 导入。今天上线了，用法如下。&amp;quot;它也给了你最好的下一个问题：它是否覆盖了他们需要的情况。&lt;/p&gt;
&lt;h2&gt;一个起步方案&lt;/h2&gt;
&lt;p&gt;从顶部的表格里挑一个时刻，选用户最常成功或者最常放弃的那一个。为它写一个问题，放在一个渠道里，在加入第二个提示之前，先把两周内的每一条回答都读一遍。对每个给了你具体内容的人，都要回复。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;应该多久向客户征集一次反馈?&lt;/strong&gt;
把询问和事件绑定，而不是和日历绑定。一个用户每周最多看到一次提示，而且刚回答完一次之后不要立刻再问。收到反馈之后的下一条消息，应该是关于它的后续处理的回复。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎样征集反馈才不会惹恼用户?&lt;/strong&gt;
在任务完成后问，绝不在任务进行中问，只问一个问题，并且让它很容易被关闭。用户关闭之后，要尊重这个选择，几周内不要再问。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;应该为反馈提供奖励吗?&lt;/strong&gt;
通常不需要。一个具体的问题加上看得见的回复，比一张礼品卡更有分量，而且奖励会吸引冲着奖品来的人。把奖励留给访谈，因为那时你要的是对方 20 分钟的时间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果没有人回答怎么办?&lt;/strong&gt;
把问题收窄，并且挪到离那个时刻更近的地方，比如只针对一个界面，在它刚被使用之后立刻问。如果依然没有动静，就直接给少数几位用户发邮件，并用这些对话来写出更好的提示。&lt;/p&gt;
</content:encoded></item><item><title>产品路线图示例：六种格式，以及各自是怎样失效的</title><link>https://changeloop.dev/blog/zh/product-roadmap-examples/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/product-roadmap-examples/</guid><description>本文给出六个带真实条目的产品路线图示例：Now/Next/Later、季度时间线、主题式、结果式、公开路线图和发布路线图，说明各自适合的读者和失效之处。</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;值得参考的产品路线图示例可以归为六种格式：Now/Next/Later、季度时间线、主题式路线图、结果式路线图、公开路线图，以及内部发布路线图。每一种回答的是不同读者的不同问题，所以合适的示例，就是与你的路线图读者相匹配的那一个。版式是最后才需要决定的事。&lt;/p&gt;
&lt;p&gt;下面的每个示例都以一个虚构的产品为背景，一款小团队任务应用，所有条目都是编造的。重点在于形状：每个格子里放什么，一条真实的条目长什么样，以及这种格式为什么会在一个季度之后失效。&lt;/p&gt;
&lt;h2&gt;好的产品路线图示例是什么样的？&lt;/h2&gt;
&lt;p&gt;好的路线图示例很简短，有明确的读者，并且只做一种承诺。按照你愿意兑现的承诺来选格式：一个方向、一个日期、一组主题工作、一个结果、一个公开的承诺，或者一份交付排期。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;格式&lt;/th&gt;
&lt;th&gt;面向谁&lt;/th&gt;
&lt;th&gt;适用于&lt;/th&gt;
&lt;th&gt;失效于&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Now/Next/Later&lt;/td&gt;
&lt;td&gt;整个公司&lt;/td&gt;
&lt;td&gt;计划经常变化&lt;/td&gt;
&lt;td&gt;&amp;quot;Next&amp;quot;越填越满，变成一条队列&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;季度时间线&lt;/td&gt;
&lt;td&gt;销售、支持、管理层&lt;/td&gt;
&lt;td&gt;日期是真实的约束&lt;/td&gt;
&lt;td&gt;日期延期，却没人去更新&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主题式&lt;/td&gt;
&lt;td&gt;领导层、新员工&lt;/td&gt;
&lt;td&gt;想解释为什么做&lt;/td&gt;
&lt;td&gt;主题宽泛到什么条目都能放进去&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结果式&lt;/td&gt;
&lt;td&gt;产品和工程团队&lt;/td&gt;
&lt;td&gt;目标可以衡量&lt;/td&gt;
&lt;td&gt;指标没有负责人，或者没有数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;公开路线图&lt;/td&gt;
&lt;td&gt;客户&lt;/td&gt;
&lt;td&gt;能保持精简&lt;/td&gt;
&lt;td&gt;沦为积压清单的倾倒场&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内部发布&lt;/td&gt;
&lt;td&gt;工程、QA、支持&lt;/td&gt;
&lt;td&gt;多个团队一起发布&lt;/td&gt;
&lt;td&gt;被误当成战略&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;每种产品路线图示例具体长什么样？&lt;/h2&gt;
&lt;p&gt;下面的每种格式都配有贴近真实的条目，之后说明它适合谁、什么时候站得住脚，以及通常是怎样失效的。&lt;/p&gt;
&lt;h3&gt;Now/Next/Later&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;NOW (本月正在构建)
  Saved views on the inbox
  CSV export that works for large accounts
NEXT (已决定，顺序未定)
  SSO for the Team plan
  Slack notifications
LATER (一个方向，不做承诺)
  Mobile app
  Audit log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种格式适合不想承诺日期的公司，很多早期团队都是如此。它之所以站得住脚，是因为三列描述的是你有多确定：&amp;quot;now&amp;quot;是正在进行，&amp;quot;next&amp;quot;是已经决定，&amp;quot;later&amp;quot;是一个期望。当&amp;quot;later&amp;quot;变成存放所有没人愿意拒绝的想法的地方，或者当&amp;quot;next&amp;quot;在没有人称之为时间线的情况下悄悄拥有了顺序和日期时，它就失效了。&lt;/p&gt;
&lt;h3&gt;时间线或季度路线图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Q4 2026
  Oct   Saved views on the inbox
  Nov   SSO beta with five design partners
  Dec   SSO general availability
Q1 2027
  Jan   Slack notifications
  Mar   Audit log (export only)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种格式适合销售、支持和财务，他们需要围绕某个东西做计划。当日期是真实的约束时，比如一份合同、一场大会或者一个合规截止日，它就能奏效。当日期只是猜测时它就会失效，因为路线图上的一个月份，几周之内就会变成销售资料里的一份承诺。如果使用这种格式，请把每个季度标注为&amp;quot;已承诺&amp;quot;或&amp;quot;预测&amp;quot;，并且让第二个季度明显比第一个季度更松。&lt;/p&gt;
&lt;h3&gt;主题式路线图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;THEME: First week experience
  Import from CSV and Trello
  Starter templates
THEME: Ready for bigger teams
  SSO
  Audit log
  Role permissions
THEME: Fewer manual steps
  Slack notifications
  Recurring tasks
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种格式适合领导层汇报和新员工，因为它在列出工作之前，先解释了这些工作为什么存在。当每个主题都对应一个客户会在意的理由时，它就站得住脚。当主题宽泛到（&amp;quot;增长&amp;quot;、&amp;quot;质量&amp;quot;）每个条目都能放进每个主题下面时，它就失效了，这时的分组什么也解释不了。&lt;/p&gt;
&lt;h3&gt;结果式路线图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;GOAL: More new teams finish setup
  Metric: setup finished within 7 days, 40% to 55%
  Bets: import from CSV, starter templates
GOAL: Fewer support tickets about exports
  Metric: export tickets per week, 30 to 10
  Bets: large-account export fix, export status page
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的数字只是示意，重点在版式：一个目标，一个有起点和目标值的指标，以及你打算尝试的几个押注。它适合那些被信任可以自行选择解决方案的产品和工程团队。当指标存在并且有人负责时，它就能奏效。当目标无法衡量，或者&amp;quot;押注&amp;quot;仍然是原来那份功能清单、只是在上面贴了一句结果描述时，它就失效了。&lt;/p&gt;
&lt;h3&gt;面向客户的公开路线图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;PLANNED
  Saved views on the inbox
BUILDING
  Slack notifications
SHIPPED
  CSV export for large accounts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是最小的格式，也做出了最强的承诺。它适合客户，他们想知道自己的请求有没有被听到。条目极少、没有日期、标题用客户自己的话来写，它就站得住脚。当它沦为积压清单的倾倒场时就失效了：你列出的每一个&amp;quot;也许&amp;quot;，都是一个别人日后会来追问的承诺。如何从你的 issue 追踪系统运行一份公开路线图，已经写在&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;三列式公开路线图&lt;/a&gt;里，这里不再重复。&lt;/p&gt;
&lt;h3&gt;内部发布路线图&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;th&gt;目标日期&lt;/th&gt;
&lt;th&gt;负责人&lt;/th&gt;
&lt;th&gt;依赖&lt;/th&gt;
&lt;th&gt;状态&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;10 月 14 日&lt;/td&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Auth service upgrade&lt;/td&gt;
&lt;td&gt;代码已完成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5.3&lt;/td&gt;
&lt;td&gt;11 月 11 日&lt;/td&gt;
&lt;td&gt;Inbox&lt;/td&gt;
&lt;td&gt;Saved views API&lt;/td&gt;
&lt;td&gt;进行中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5.4&lt;/td&gt;
&lt;td&gt;12 月 9 日&lt;/td&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;SSO vendor contract&lt;/td&gt;
&lt;td&gt;受阻&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这种格式适合工程、QA 和支持，他们需要知道哪些内容会一起发布，以及什么阻塞了什么。当它精确到周、并且每一行都有负责人时，它就能奏效。当有人把它当成战略时它就失效了：交付排期说明的是什么会在什么时候发出去，而对于这些版本是不是正确的押注，它什么也没说。&lt;/p&gt;
&lt;h2&gt;应该选择哪种产品路线图格式？&lt;/h2&gt;
&lt;p&gt;先按读者来选，再按你实际拥有的确定性来选。如果你说不出谁会读这份路线图、它帮助他们做出什么决定，那么上面任何一个示例都救不了它。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;客户在问&amp;quot;你们听到我了吗？&amp;quot;&lt;/strong&gt; 用公开格式，并且只保留寥寥几项。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;销售和支持在问&amp;quot;我能告诉客户一个日期吗？&amp;quot;&lt;/strong&gt; 用季度时间线，把已承诺和预测清楚地分开。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;领导层在问&amp;quot;为什么做这些？&amp;quot;&lt;/strong&gt; 用主题式，如果有数据就用结果式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个每月都在改变方向的团队。&lt;/strong&gt; 用 Now/Next/Later，并且克制住不加日期的冲动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;工程师在问&amp;quot;什么时候发布什么？&amp;quot;&lt;/strong&gt; 用发布路线图，并且让它与战略路线图分开。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大多数团队最终会拥有两份：一份是前四种形态之一的战略路线图，下面是一份发布排期。公开路线图则是战略路线图的一个过滤视图，只展示你愿意被追责的内容。&lt;/p&gt;
&lt;h2&gt;如何编写产品路线图？&lt;/h2&gt;
&lt;p&gt;编写路线图的方法是：先确定读者，选择适合他们问题的格式，只列出你会在会议上为之辩护的条目，并给每个条目一个状态和一位负责人。然后在发布之前，决定它多久评审一次。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;确定读者和他们要做的决定。&lt;/strong&gt; &amp;quot;支持团队要决定如何向客户介绍 SSO&amp;quot;是一个理由。&amp;quot;所有人都应该看到路线图&amp;quot;则没有给你任何可以设计的依据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;从你已经知道的东西出发。&lt;/strong&gt; 未处理的请求，按照&lt;a href=&quot;https://changeloop.dev/blog/zh/prioritizing-feature-requests/&quot;&gt;一条你能解释清楚的规则排好序&lt;/a&gt;，比一场头脑风暴是更好的原材料。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把每个条目写成客户的结果。&lt;/strong&gt; &amp;quot;保存一个你常用的筛选条件&amp;quot;比&amp;quot;实现已保存视图的持久化&amp;quot;读起来更好，也能让客户判断这是不是他的问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;决定路线图不包含什么。&lt;/strong&gt; 日期、估算和想法积压清单，是三种常见的排除项。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;设定一个评审日期。&lt;/strong&gt; 一份没有安排评审的路线图，就等于没有安排日期的葬礼。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;如何让产品路线图保持最新？&lt;/h2&gt;
&lt;p&gt;让路线图保持最新的办法，是在工作推进时移动条目，并且从追踪这项工作的同一个地方来做，同时在条目上线或被放弃时记录发生了什么。一份有人在另一个工具里手动更新的路线图之所以会陈旧，是因为它不是任何人的日常工作。&lt;/p&gt;
&lt;p&gt;最廉价的事实来源是 issue 追踪系统。如果路线图的每一列都对应 issue 上的一个标签，那么标签一变，路线图就跟着变，什么都不需要重新录入。Changeloop 的做法使用 &lt;code&gt;roadmap:planned&lt;/code&gt;、&lt;code&gt;roadmap:building&lt;/code&gt; 和 &lt;code&gt;roadmap:shipped&lt;/code&gt; 标签，当一个 issue 同时带有两个标签时，更靠后的那个胜出。把卡片移到已发布依然是一次单独的标签变更，所以要把它纳入你批准体验日志条目的那次评审。&lt;/p&gt;
&lt;p&gt;条目是另一半。当一个项目上线时，体验日志会用客户的话说明改变了什么，提出过请求的人也可以得到通知。闭合这个循环，正是&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;客户反馈循环&lt;/a&gt;的要点，而路线图就是这个循环里、在任何东西发布之前客户能看到的那一段。如果你放弃了一个项目，就要说出来；一句公开的&amp;quot;不做&amp;quot;同样会结束这个请求，&lt;a href=&quot;https://changeloop.dev/blog/zh/declining-feature-requests/&quot;&gt;拒绝功能请求&lt;/a&gt;里介绍了如何措辞。想看已完成的条目读起来是什么样子，可以浏览&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志示例&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;最简单的产品路线图格式是什么?&lt;/strong&gt;
Now/Next/Later。它只有三列，不需要日期，按确定性来给条目分组。对一个经常改变方向的小团队来说，它也是最不容易出大糗的格式。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;产品路线图应该有多少个条目?&lt;/strong&gt;
比你想的要少。对公开路线图来说，所有列加起来不到十项就够了，一份内部战略路线图很少需要超过十来项。再多就成了一份换了个好看标题的积压清单。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;产品路线图应该包含日期吗?&lt;/strong&gt;
只有当日期是真实的约束时才需要，而且只写最近的一个季度。再往后，就用列或者主题。路线图上的日期，不管你是不是有意为之，都会在销售对话里变成一份承诺。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;产品路线图和发布计划有什么区别?&lt;/strong&gt;
路线图说明你打算构建什么以及为什么。发布计划说明哪个版本在哪天发布、由谁负责。路线图随着你的战略变化而变化，发布计划则随着工作的进展而变化。&lt;/p&gt;
</content:encoded></item><item><title>面向频繁发布团队的发布管理流程：七个步骤与关键指标</title><link>https://changeloop.dev/blog/zh/release-management-process/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/release-management-process/</guid><description>本文介绍面向软件团队的七步发布管理流程，每步有明确负责人和退出标准，并介绍 DORA 指标和一项值得自己追踪的 KPI。</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;发布管理流程，是把一项变更从&amp;quot;已合并&amp;quot;带到&amp;quot;已在生产环境运行，并且向受影响的人解释清楚&amp;quot;的一整套步骤。对于经常发布的团队来说，它归结为七个步骤：规划范围、隔离变更、构建与测试、审批、部署与验证、沟通，以及复盘。每个步骤都需要一位具名的负责人和一条退出标准，否则它就会悄悄地不再发生。&lt;/p&gt;
&lt;p&gt;本指南假设团队有 5 到 50 名工程师，每周或每天部署，并且希望流程不要碍事。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;步骤&lt;/th&gt;
&lt;th&gt;负责人&lt;/th&gt;
&lt;th&gt;退出标准&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1. 规划范围&lt;/td&gt;
&lt;td&gt;产品负责人或技术负责人&lt;/td&gt;
&lt;td&gt;本次发布包含的变更清单已写下，有风险的内容已标出&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. 分支或开关&lt;/td&gt;
&lt;td&gt;负责这项变更的工程师&lt;/td&gt;
&lt;td&gt;工作在一个短生命周期的分支上或藏在开关之后，让主干始终可发布&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 构建与测试&lt;/td&gt;
&lt;td&gt;CI，作者在故障时待命&lt;/td&gt;
&lt;td&gt;即将发布的那个确切提交上，流水线全绿&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. 审批&lt;/td&gt;
&lt;td&gt;评审者，有风险的变更再加发布经理&lt;/td&gt;
&lt;td&gt;评审完成，回滚路径已明确，通过或不通过的决定已记录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. 部署与验证&lt;/td&gt;
&lt;td&gt;发布经理或值班工程师&lt;/td&gt;
&lt;td&gt;已部署，冒烟检查通过，错误率和延迟与发布前的基线一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. 沟通&lt;/td&gt;
&lt;td&gt;理解这项变更的人，由不理解的人来编辑&lt;/td&gt;
&lt;td&gt;发布说明已发布在用户阅读的地方，支持和销售已被告知&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7. 复盘&lt;/td&gt;
&lt;td&gt;发布经理&lt;/td&gt;
&lt;td&gt;指标已查看，出过的问题都有负责人和修复方案&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;什么是发布管理流程？&lt;/h2&gt;
&lt;p&gt;它是一项变更触达用户所经过的、可重复的路径：范围、构建、测试、审批、部署、验证、公告，以及回顾。把它写下来的意义在于，每一次发布都走同一条路径，所以一个正在休假的人、一位新员工，或者凌晨两点的值班工程师，都可以直接执行，而不必去问别人它是怎么运作的。&lt;/p&gt;
&lt;h2&gt;发布管理有哪些不同类型？&lt;/h2&gt;
&lt;p&gt;实践中有三种类型：持续部署、定期发布，以及受监管的变更管理。它们的区别在于发布之前要做多少事，以及有多少是自动化的。持续部署会发布每一项已合并的变更，定期发布把变更批量打包成一趟班车，而受监管的变更管理则增加了正式的审批和审计记录。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;持续部署&lt;/th&gt;
&lt;th&gt;定期发布&lt;/th&gt;
&lt;th&gt;受监管或 ITIL 变更管理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;发布单元&lt;/td&gt;
&lt;td&gt;一个已合并的 pull request&lt;/td&gt;
&lt;td&gt;一个批次，每周或每两周&lt;/td&gt;
&lt;td&gt;一份变更申请&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;范围步骤&lt;/td&gt;
&lt;td&gt;隐含在合并之中，合并就是范围&lt;/td&gt;
&lt;td&gt;发布规划会议&lt;/td&gt;
&lt;td&gt;带风险评级的变更记录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;审批&lt;/td&gt;
&lt;td&gt;代码评审加自动化检查&lt;/td&gt;
&lt;td&gt;发布经理为整个批次签字&lt;/td&gt;
&lt;td&gt;变更咨询委员会或被委派的审批人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;风险控制&lt;/td&gt;
&lt;td&gt;功能开关、金丝雀发布、快速回滚&lt;/td&gt;
&lt;td&gt;预发布环境浸泡、候选版本&lt;/td&gt;
&lt;td&gt;书面的回退方案、维护窗口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;典型节奏&lt;/td&gt;
&lt;td&gt;每天多次&lt;/td&gt;
&lt;td&gt;每周到每月&lt;/td&gt;
&lt;td&gt;由变更日历决定&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;薄弱环节&lt;/td&gt;
&lt;td&gt;没有人告诉用户发生了什么变化&lt;/td&gt;
&lt;td&gt;大批次会掩盖是哪项变更搞坏了东西&lt;/td&gt;
&lt;td&gt;流程耗时远超变更本身&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;大多数团队是混合的。一个 SaaS 产品可能持续部署，而它的移动应用按每周一趟班车发布，审计人员关心的那个支付服务，则遵循正式的变更记录。按服务来选择类型，而不是按公司。当变更只是逐步暴露时，发布和公告就成了两个独立的事件，这正是 &lt;a href=&quot;https://changeloop.dev/blog/zh/feature-flags-feature-requests/&quot;&gt;feature flag 发布说明&lt;/a&gt;所讨论的情形。&lt;/p&gt;
&lt;h2&gt;发布经理的职责是什么？&lt;/h2&gt;
&lt;p&gt;发布经理负责一项变更到达生产环境所经过的路径。他们维护发布日历，判断一项变更是否就绪，执行或监督部署，做出回滚的决定，确保用户被告知，并在事后主持复盘。&lt;/p&gt;
&lt;p&gt;发布之前，他们确认范围，并检查每一项有风险的变更都有回滚路径。发布期间，他们执行部署检查清单，盯住生产指标最初的几分钟，并尽早决定回滚。发布之后，他们确认说明已经发出，并记录流程中需要修正的地方。&lt;/p&gt;
&lt;p&gt;在小团队里，让这个角色每周轮换，并把检查清单写好，这样就没有人需要依赖口口相传的经验。一个包含许多独立发布的软件包的 &lt;a href=&quot;https://changeloop.dev/blog/zh/monorepo-changelogs/&quot;&gt;monorepo&lt;/a&gt;，通常每个软件包都需要一位发布负责人，否则这个角色就会变成瓶颈。&lt;/p&gt;
&lt;h2&gt;发布管理的关键 KPI 有哪些？&lt;/h2&gt;
&lt;p&gt;追踪 DORA 的软件交付指标，再加上你自己的一项：用户被告知所需的时间。DORA 的研究确定了五项指标，分为吞吐量（变更前置时间、部署频率、失败部署恢复时间）和不稳定性（变更失败率、部署返工率）两类。&lt;/p&gt;
&lt;p&gt;DORA 的指南用平实的语言定义了它们（&lt;a href=&quot;https://dora.dev/guides/dora-metrics/&quot;&gt;dora.dev, software delivery metrics&lt;/a&gt;）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KPI&lt;/th&gt;
&lt;th&gt;它衡量什么&lt;/th&gt;
&lt;th&gt;需要留意什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;变更前置时间&lt;/td&gt;
&lt;td&gt;从版本控制中提交，到部署到生产环境的时间&lt;/td&gt;
&lt;td&gt;数字上升通常意味着评审或审批中出现了排队&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;部署频率&lt;/td&gt;
&lt;td&gt;你多久部署一次，或两次部署之间的时间&lt;/td&gt;
&lt;td&gt;频率下降意味着批次在变大&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;失败部署恢复时间&lt;/td&gt;
&lt;td&gt;从一次需要立即介入的部署中恢复所需的时间&lt;/td&gt;
&lt;td&gt;回滚和告警方面的问题会在这里显现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;变更失败率&lt;/td&gt;
&lt;td&gt;需要回滚或热修复的部署所占的比例&lt;/td&gt;
&lt;td&gt;批次过大或测试不足时会上升&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;部署返工率&lt;/td&gt;
&lt;td&gt;因生产事故而产生的计划外部署所占的比例&lt;/td&gt;
&lt;td&gt;说明修复发布的速度快过了吸取教训的速度&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户被告知所需的时间&lt;/td&gt;
&lt;td&gt;从生产部署到发布面向用户的说明的分钟数&lt;/td&gt;
&lt;td&gt;需要自己测量，没有任何框架会提供它&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;较早的资料列出四项关键指标，并把恢复称为&amp;quot;恢复服务时间&amp;quot;。当前的指南使用上面这五项。&lt;/p&gt;
&lt;p&gt;同一份指南还提醒不要把这些指标当作目标。设定诸如&amp;quot;到年底所有服务每天部署多次&amp;quot;这样的目标，会诱使团队去操纵数字，而且这些指标应该按应用或服务来解读，而不是在整个公司范围内混在一起。它为改善所有这些指标给出的实用建议，是缩小每次变更的规模，因为更小的变更更容易评审，更容易通过流水线，也更容易恢复。&lt;/p&gt;
&lt;h2&gt;发布沟通在发布管理流程中处于什么位置？&lt;/h2&gt;
&lt;p&gt;它是第六步，和其他每一步一样，有负责人，也有退出标准：说明已发布在用户阅读的地方，内部团队已被告知。团队最常跳过的就是这一步，因为部署工具在代码上线的那一刻就报告成功了。&lt;/p&gt;
&lt;p&gt;让这一步按时完成，最省事的办法是在变更合并时就写好条目，而不是等发布时才写。pull request 里已经有标题、作者、关联的 issue 和上下文。据此生成的草稿是拿来编辑的，而不是一周之后凭记忆去写。这正是&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;背后的思路：在合并时生成草稿，留给人批准，然后从同一个来源发布到各处。Changeloop 就是这样工作的，它用 AI 从已合并的 pull request 起草条目，并在任何内容发布之前留给人批准。&lt;/p&gt;
&lt;p&gt;有两种变体值得提前计划。支持和销售需要一份与客户不同的说明，这就是&lt;a href=&quot;https://changeloop.dev/blog/zh/internal-release-notes/&quot;&gt;内部发布说明&lt;/a&gt;的用途。由事故驱动的发布来不及走正常的起草流程，所以要备好一份简短的模板，如&lt;a href=&quot;https://changeloop.dev/blog/zh/emergency-release-notes/&quot;&gt;紧急发布说明&lt;/a&gt;所述。&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;为面向客户的版本提供了一个起步的形状。&lt;/p&gt;
&lt;h2&gt;如何让流程保持轻量？&lt;/h2&gt;
&lt;p&gt;把机器能检查的每一条退出标准都自动化，把人留给需要判断的事情。流水线全绿、仪表盘上的部署标记，以及每个已合并 pull request 对应的一条体验日志草稿，都是可以检查的。回滚方案是否可信，或者说明对客户来说是否说得通，则需要人来判断。&lt;/p&gt;
&lt;p&gt;要检验这个流程，挑上个月的一次发布，问问团队之外的人，是否仅凭书面记录，就能说出发布了什么、谁批准的、怎样验证的，以及用户是什么时候被告知的。任何缺口，都是你下一个要改进的地方。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;发布管理和变更管理有什么区别?&lt;/strong&gt;
发布管理让一组变更得以构建、测试、部署和公告。变更管理（按 ITIL 的含义）则是围绕每项变更的审批和风险流程。经常发布的团队，会把审批并入代码评审和自动化检查。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我们应该多久发布一次?&lt;/strong&gt;
在你的测试和回滚路径允许的范围内越频繁越好，对许多 Web 团队来说是每天或更多。DORA 的建议是缩小每次变更的规模，因为小的变更更容易评审和恢复。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;小团队需要发布经理吗?&lt;/strong&gt;
他们需要这些职责，但不一定需要这个头衔。让工程师轮流担任这个角色，给轮值的人一份书面的检查清单，并确保七个步骤中的每一步都有人负责。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布检查清单应该包含什么?&lt;/strong&gt;
范围已确认，发布的那个提交上流水线全绿，回滚路径已明确，审批已记录，部署后做冒烟检查，指标与基线对比，发布说明已发布，支持已被告知，并安排好复盘。控制在一页之内。&lt;/p&gt;
</content:encoded></item><item><title>发布说明示例：针对每一种变更类型的写法，以及为什么有效</title><link>https://changeloop.dev/blog/zh/release-notes-examples/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/release-notes-examples/</guid><description>本文给出新功能、改进、缺陷修复、破坏性变更、安全修复、弃用、应用商店说明和内部说明的发布说明示例，并解释为什么有效，让你照搬形状，换上自己产品的真实事实。</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最好的发布说明示例都很简短，会点明谁受到影响，并说清楚下一步该做什么。下面为你将要发布的每一种变更类型各给出一个示例，并解释它为什么有效，这样你就可以照搬它的形状，换上自己的事实。&lt;/p&gt;
&lt;p&gt;所有示例都是虚构的，背景是一个名叫 Tidepool 的虚构开票应用。&lt;/p&gt;
&lt;h2&gt;好的发布说明示例有哪些共同点？&lt;/h2&gt;
&lt;p&gt;它们用用户自己的话，告诉用户发生了什么变化，以及他们（如果需要的话）该做什么。每种变更类型承担的任务不同，所以形状也随之变化。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变更类型&lt;/th&gt;
&lt;th&gt;条目必须说明&lt;/th&gt;
&lt;th&gt;放在哪里&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;新功能&lt;/td&gt;
&lt;td&gt;读者现在能做什么，以及谁能用到&lt;/td&gt;
&lt;td&gt;发布说明的最上方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;改进&lt;/td&gt;
&lt;td&gt;什么变得更快或更容易，有数字就写上数字&lt;/td&gt;
&lt;td&gt;功能之后&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;缺陷修复&lt;/td&gt;
&lt;td&gt;读者看到的症状，以及它已经修好&lt;/td&gt;
&lt;td&gt;改进之后&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;破坏性变更&lt;/td&gt;
&lt;td&gt;谁受影响、日期、迁移方法&lt;/td&gt;
&lt;td&gt;永远放在最前面&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全修复&lt;/td&gt;
&lt;td&gt;暴露了什么、是否被利用、该怎么做&lt;/td&gt;
&lt;td&gt;最前面&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;弃用&lt;/td&gt;
&lt;td&gt;什么将被移除、截止日期、替代方案&lt;/td&gt;
&lt;td&gt;靠近顶部&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;应用商店说明&lt;/td&gt;
&lt;td&gt;每项变更一句平实的话，不超过字数限制&lt;/td&gt;
&lt;td&gt;商店页面&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内部说明&lt;/td&gt;
&lt;td&gt;变化了什么，以及该如何向客户说明&lt;/td&gt;
&lt;td&gt;支持和销售渠道&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;好的新功能说明是什么样的？&lt;/h2&gt;
&lt;p&gt;好的功能说明，开头就写读者现在能做什么，并点明哪些套餐或角色可以使用。它会跳过实现细节。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;用客户的语言发送发票。&lt;/strong&gt;
你现在可以为每位客户选择一种语言，他们的发票、提醒和付款页面都会随之切换。法语、德语、西班牙语和葡萄牙语适用于所有套餐。请在客户页面的&amp;quot;账单偏好&amp;quot;下进行设置。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;标题是读者会大声说出来的一句话，正文则给出范围和位置。只扫一眼加粗那一行的读者，也知道发布了什么。更完整的方法见&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;如何写出用户真正愿意花时间读完的发布说明&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;好的改进说明是什么样的？&lt;/h2&gt;
&lt;p&gt;改进说明描述的是读者能感受到的变化，如果有实测的数字，就把数字写上去。没有数字的话，就说明读者不用再做什么了。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;发票列表的加载速度提升了约三倍。&lt;/strong&gt;
发票超过 5000 张的账户，以前要等大约九秒才能看到列表，现在大约三秒就能打开。无需任何操作。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;quot;性能改进&amp;quot;什么也没告诉读者，而九秒对三秒，是他们周一早上就能自己验证的说法。结尾的&amp;quot;无需任何操作&amp;quot;，回答了每位读者都会有的那个问题。&lt;/p&gt;
&lt;h2&gt;好的缺陷修复说明是什么样的？&lt;/h2&gt;
&lt;p&gt;缺陷修复说明描述的是用户看到的症状，而不是代码里的原因，并且说明他们是否需要重做什么。没有人注意到的修复，可以放在底部的列表里。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;已修复：提醒邮件在到期日被发送了两次。&lt;/strong&gt;
如果发票的到期日恰好是某个月的最后一天，部分客户会收到两封一模一样的提醒。该问题已修复。已经发出的提醒不受影响，也无需任何人重新发送。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;标题以&amp;quot;已修复&amp;quot;开头，浏览的人一眼就能分类，而真正的触发条件（月底最后一天）紧随其后。&lt;/p&gt;
&lt;h2&gt;如何为破坏性变更撰写发布说明？&lt;/h2&gt;
&lt;p&gt;破坏性变更的说明，先写日期和受影响的人群，再在同一条目里给出迁移方法。它在发布说明里排在最前面，因为这是读者绝不能错过的那一条。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;自 2026 年 12 月 1 日起，Webhook 签名将成为必需项。&lt;/strong&gt;
从这一天起，Tidepool 将不再发送未签名的 webhook 负载。这会影响所有接收 webhook 却不检查 &lt;code&gt;Tidepool-Signature&lt;/code&gt; 请求头的用户。迁移方法：使用&amp;quot;设置，开发者&amp;quot;下的密钥来验证该请求头。如果你已经在验证签名，则无需任何操作。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;日期写在标题里，所以即使只是扫读也不会错过。受影响的人群是按他们的行为来界定的，而最后一句话让已经没问题的人放了心，这样就减轻了支持的负担。关于如何判断一项变更是否算数，&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;破坏性变更&lt;/a&gt;这篇指南里有详细说明。&lt;/p&gt;
&lt;h2&gt;安全修复的说明是什么样的？&lt;/h2&gt;
&lt;p&gt;安全说明要写清楚暴露了什么、是否有人利用过、谁受影响，以及他们必须做什么。保持客观和冷静。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;安全：密码重置链接可能被重复使用。&lt;/strong&gt;
在 2026 年 9 月 3 日至 17 日期间，密码重置链接在使用过一次之后仍然保持有效。我们没有发现任何迹象表明它被利用过。该问题已修复，所有尚未使用的重置链接都已失效。如果你在这段时间内申请过重置，请重新申请一个新的链接。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;确切的时间窗口让读者能够判断自己的暴露程度，而关于是否被利用的那句话，回答了每个人首先会问的问题。&amp;quot;一个潜在的问题&amp;quot;读起来像是在隐瞒，所以把你知道的如实写出来。&lt;/p&gt;
&lt;h2&gt;如何撰写弃用通知？&lt;/h2&gt;
&lt;p&gt;弃用通知要说明什么将被移除，给出一个确定的截止日期，并指向替代方案。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;v1 发票接口已弃用，将于 2027 年 3 月 1 日终止。&lt;/strong&gt;
&lt;code&gt;GET /v1/invoices&lt;/code&gt; 会继续工作到 2027 年 3 月 1 日，之后将返回 &lt;code&gt;410 Gone&lt;/code&gt;。请改用 &lt;code&gt;GET /v2/invoices&lt;/code&gt;，它返回相同的字段，另外多了 &lt;code&gt;currency&lt;/code&gt;。现在 v1 的响应会附带一个包含截止日期的 &lt;code&gt;Sunset&lt;/code&gt; 响应头。文档中有一份并排对照的迁移指南。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;接口名称写在标题里，因为受影响的人会去搜索它，而替代方案就放在移除说明的旁边。&lt;code&gt;Sunset&lt;/code&gt; 响应头会告诉开发者，哪些调用仍在使用旧版本。更完整的论述见&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;弃用 API&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;应用商店的发布说明是什么样的？&lt;/h2&gt;
&lt;p&gt;应用商店说明是两三句平实的话，因为大多数人只会读第一行。开头写用户能注意到的那项变更。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;扫描一张纸质收据，Tidepool 会自动填好金额、日期和商家。深色模式现在会跟随你手机的设置。我们还修复了从通知打开发票时发生的崩溃。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;最有用的变更放在最前面，而修复则点明了发生崩溃的场景。没有版本号，也没有&amp;quot;错误修复与改进&amp;quot;这种话。&lt;a href=&quot;https://changeloop.dev/blog/zh/mobile-app-release-notes/&quot;&gt;移动应用的发布说明&lt;/a&gt;介绍了各个商店专有的规则。&lt;/p&gt;
&lt;h2&gt;内部发布说明应该包含什么？&lt;/h2&gt;
&lt;p&gt;内部说明是写给支持和销售的版本。它补上公开说明里省略的内容：该怎么说，以及不要承诺什么。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;多语言发票今天上线（所有套餐）。&lt;/strong&gt;
支持团队：客户在&amp;quot;账单偏好&amp;quot;下设置语言，已有的发票会保留原来的语言。意大利语暂不可用。销售团队：这项功能向所有套餐开放，所以不要把它当作升级卖点来宣传。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;每类受众都有自己带标签的一行，而这条说明在客户开口询问之前，就划清了边界（&amp;quot;意大利语暂不可用&amp;quot;）。&lt;a href=&quot;https://changeloop.dev/blog/zh/internal-release-notes/&quot;&gt;内部发布说明&lt;/a&gt;一文介绍了格式和渠道。&lt;/p&gt;
&lt;h2&gt;一条糟糕的发布说明改写之后是什么样的？&lt;/h2&gt;
&lt;p&gt;糟糕的发布说明列出的是团队做了什么，而不是读者得到了什么。修正的办法是把结果挪到前面，并删掉内部术语。&lt;/p&gt;
&lt;p&gt;修改前：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;v3.8.1&lt;/strong&gt; 重构了提醒调度器。修复了 &lt;code&gt;ReminderJob&lt;/code&gt; 中的竞态条件。将 &lt;code&gt;bull&lt;/code&gt; 更新到 4.12。其他改进。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;修改后：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;提醒邮件不会再发送两次。&lt;/strong&gt;
如果发票的到期日恰好是某个月的最后一天，客户可能会收到两封提醒。该问题已修复，已经发出的提醒不需要重新发送。无需任何操作。&lt;/p&gt;
&lt;p&gt;3.8.1 中还包括：&lt;code&gt;bull&lt;/code&gt; 已更新到 4.12。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;依赖升级被降到了页脚的一行，而竞态条件则变成了客户能认出来的症状。&lt;/p&gt;
&lt;h2&gt;如何让各个版本的发布说明保持一致？&lt;/h2&gt;
&lt;p&gt;在变更合并时就起草每一条，并让一个人在发布之前批准它。&lt;/p&gt;
&lt;p&gt;Changeloop 就是这样工作的：它用 AI 从每个已合并的 pull request 起草一条条目，并把它留给人来批准。批准这一步，正是编辑应用上述规则的地方。如果想先确定格式，可以从&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;开始，已完成的页面是什么样子，请参阅&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志示例&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;什么是新的发布说明?&lt;/strong&gt;
新的发布说明，就是随产品最新版本一起发布的那条消息，描述发生了什么变化以及用户需要做什么。它涵盖功能、改进、修复和破坏性变更。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明和体验日志有什么区别?&lt;/strong&gt;
体验日志什么都记，留给想看完整历史的人。发布说明则从中挑选：只讲一次发布，写给要判断它与自己是否相关的读者。更完整的对比见&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明的区别&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明是什么意思?&lt;/strong&gt;
发布说明告诉用户一次发布改了什么。这个词涵盖任何解释发布了什么的简短文档，从应用商店里的&amp;quot;新功能&amp;quot;文字，到公司网站上的一个页面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每条发布说明应该写多长?&lt;/strong&gt;
大多数条目写两到四句话就够了：结果、谁受影响，以及该做什么。破坏性变更或安全修复可能会更长一些，因为它需要日期或迁移方法。&lt;/p&gt;
</content:encoded></item><item><title>Stripe API 版本控制的运作方式，以及值得借鉴的做法</title><link>https://changeloop.dev/blog/zh/stripe-api-versioning/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/stripe-api-versioning/</guid><description>Stripe API 版本控制把每个账户固定在一个带日期的版本上，并允许单次请求覆盖它。本文讲这套机制如何运作、代价是什么，以及小型 API 可以借鉴哪些做法。</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Stripe API 版本控制是按日期来运作的。每个账户都被固定在一个以发布日期命名的 API 版本上，而任何一次请求，都可以用 &lt;code&gt;Stripe-Version&lt;/code&gt; 请求头覆盖这个固定版本。截至本文撰写时（2026 年 10 月），Stripe 文档中的当前版本是 &lt;code&gt;2026-09-30.endive&lt;/code&gt;，而同样的方案，一个小得多的 API 一个周末就能照着实现。&lt;/p&gt;
&lt;p&gt;下面所有关于 Stripe 的事实，都来自 Stripe 自己的页面，并在用到的地方附上了链接。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;Stripe 的做法&lt;/th&gt;
&lt;th&gt;来源&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;版本名称&lt;/td&gt;
&lt;td&gt;一个日期，自 2024 年起再加一个发布名称（&lt;code&gt;2026-09-30.endive&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.stripe.com/api/versioning&quot;&gt;Versioning&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;默认版本&lt;/td&gt;
&lt;td&gt;固定在账户上，在 Workbench 中修改&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.stripe.com/api/versioning&quot;&gt;Versioning&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单次请求覆盖&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Stripe-Version&lt;/code&gt; 请求头，或 SDK 选项&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.stripe.com/upgrades&quot;&gt;Upgrades&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook&lt;/td&gt;
&lt;td&gt;以端点上设置的版本渲染&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.stripe.com/upgrades&quot;&gt;Upgrades&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发布节奏&lt;/td&gt;
&lt;td&gt;每月发布不含破坏性变更的版本，每年两次大版本发布&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.stripe.com/api/versioning&quot;&gt;Versioning&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;旧版本&lt;/td&gt;
&lt;td&gt;通过内部的版本变更模块保持可用&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://stripe.com/blog/api-versioning&quot;&gt;Engineering post&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Stripe API 版本控制是如何运作的？&lt;/h2&gt;
&lt;p&gt;Stripe 为每个账户设定一个默认 API 版本，而每一个没有指明版本的请求，都使用这个版本。调用方自己选择何时迁移，办法是修改默认版本，或者在单个请求上设置版本。&lt;/p&gt;
&lt;p&gt;Stripe 的工程博客文章说，账户在第一次发出 API 请求时就被固定了：该账户&amp;quot;自动被固定在当时可用的最新版本&amp;quot;上，此后每一次调用都隐式地被分配这个版本。&lt;/p&gt;
&lt;p&gt;版本字符串是一个日期。自 &lt;code&gt;2024-09-30.acacia&lt;/code&gt; 发布起，它还带有一个名称，例如 &lt;code&gt;2026-09-30.endive&lt;/code&gt;。日期决定版本的先后顺序，名称则告诉你这个版本属于哪个大版本系列。&lt;/p&gt;
&lt;h2&gt;如何为每个请求选择版本？&lt;/h2&gt;
&lt;p&gt;在请求上发送 &lt;code&gt;Stripe-Version&lt;/code&gt; 请求头，或者在 SDK 中设置版本。Stripe 的升级指南展示了请求头的写法，同样的调用在正式环境和测试环境中都适用。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;curl https://api.stripe.com/v1/charges \
  -u &amp;quot;$STRIPE_SECRET_KEY:&amp;quot; \
  -H &amp;quot;Stripe-Version: 2026-09-30.endive&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Stripe 的指南指出，当你在 SDK 中全局设置版本或者按请求设置版本时，返回的响应对象就是该版本的形态。&lt;/p&gt;
&lt;p&gt;Stripe 也建议不要依赖账户的默认版本。用它的话说，应该为每个请求指定版本，使用请求头或者固定版本的 SDK，这样决定版本的是你的代码，而不是仪表盘上的某项设置。&lt;/p&gt;
&lt;p&gt;不同语言的 SDK 固定版本的方式不同。文档说，动态类型语言库的近期版本，使用该 SDK 发布时最新的 API 版本，而强类型的库（Java、Go 和 .NET）则固定在这个版本上。安装某个库版本，实际上就是在选择一个 API 版本。&lt;/p&gt;
&lt;h2&gt;版本变化时，webhook 会怎样？&lt;/h2&gt;
&lt;p&gt;一个 webhook 事件，是按照其端点所关联的 API 版本来渲染的，而不是按你的服务器代码所使用的版本。Stripe 的文档说，事件使用创建端点时设置的版本，否则使用账户默认版本。更改你的 SDK 版本，并不会改变 webhook 处理程序收到的内容。&lt;/p&gt;
&lt;p&gt;因此，你的请求路径和事件路径可能处于两个不同的版本上。对于事件目标，&lt;code&gt;snapshot_api_version&lt;/code&gt; 只能在创建目标时设置，所以要换一个版本就得新建一个目标。&lt;/p&gt;
&lt;p&gt;Stripe 给出的升级路径是并行运行。创建一个目标版本的新端点，把同样的事件发送给两个端点，让处理程序处理其中一个、忽略另一个，然后切换并停用旧端点。由于在重叠期间每个事件都会到达两次，处理程序必须是幂等的。对任何会发出事件的 API 来说，这都是一个值得借鉴的好模式，而&lt;a href=&quot;https://changeloop.dev/blog/zh/webhook-changelog/&quot;&gt;Webhook 体验日志&lt;/a&gt;正是你公布那些使这一做法成为必要的负载变更的地方。&lt;/p&gt;
&lt;h2&gt;月度发布和大版本发布是什么？&lt;/h2&gt;
&lt;p&gt;自 &lt;code&gt;2024-09-30.acacia&lt;/code&gt; 发布起，Stripe 每月发布一个不含破坏性变更的新 API 版本，并且每年两次发布新的大版本，以一个包含破坏性变更的版本为开端。它的版本控制页面说，你可以升级到任何一个月度版本而无需修改代码，而大版本则可能需要改动。&lt;/p&gt;
&lt;p&gt;大版本带有名称。版本控制页面以 Basil 为例，而 Stripe 关于这一流程的公告说，名称取自植物，从 Acacia 开始，月度版本沿用之前那个大版本的名称，这样名称就表明可以放心升级。Stripe 的&lt;a href=&quot;https://docs.stripe.com/changelog&quot;&gt;变更日志&lt;/a&gt;列出了在用的名称，截至本文撰写时，最新的一条是 &lt;code&gt;2026-09-30.endive&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以，日期回答的是&amp;quot;有多新&amp;quot;，名称回答的是&amp;quot;这里是不是一个破坏性的分界点&amp;quot;。Stripe 的公告还为例外留出了空间：如果不发布就会严重影响某个集成，它保留在周期之外发布破坏性变更的权利。公告见 &lt;a href=&quot;https://stripe.com/blog/introducing-stripes-new-api-release-process&quot;&gt;Stripe&amp;#39;s new API release process&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;Stripe API 的最新版本是什么？&lt;/h2&gt;
&lt;p&gt;截至本文撰写时（2026 年 10 月），Stripe 的版本控制页面声明当前版本是 &lt;code&gt;2026-09-30.endive&lt;/code&gt;，它的变更日志也把同一个版本列为最新。Stripe 每月发布一个新版本，所以任何印在文章里的字符串都会很快过时。在固定任何版本之前，请先查看实时的变更日志，并固定你测试过的那个版本。&lt;/p&gt;
&lt;h2&gt;Stripe 如何让旧版本继续可用？&lt;/h2&gt;
&lt;p&gt;Stripe 的做法是，把每一项破坏性变更写成一个自包含的版本变更模块，并从数据的最新形态开始，向后依次应用这些模块。它的&lt;a href=&quot;https://stripe.com/blog/api-versioning&quot;&gt;关于 API 版本控制的工程文章&lt;/a&gt;描述了这一机制。&lt;/p&gt;
&lt;p&gt;每个模块都声明自己改变了什么，记录这项变更，并包含一个转换函数。文章举了一个例子：某个字段从字符串变成了哈希。要构建一个响应，系统先确定目标版本，然后沿着时间向后回溯，沿途遇到的每个模块都依次应用，直到抵达那个版本。&lt;/p&gt;
&lt;p&gt;这个设计带来两个副作用，文章里都提到了。其一，由于模块声明了它们所涉及的字段和资源，Stripe 可以在部署时据此生成它的 API 体验日志。其二，由于账户的版本是已知的，文档就可以根据它做出调整，并对自该版本以来的向后不兼容变更给出警告。&lt;/p&gt;
&lt;h2&gt;它的代价是什么，较小的 API 应该借鉴什么？&lt;/h2&gt;
&lt;p&gt;版本控制需要消耗工程上的注意力，Stripe 自己也这么说。工程文章承认存在维护负担，并提出了这样的目标：编写新代码时，需要为旧行为考虑得越少越好。文章还描述了发布之前的轻量级 API 评审，目的是从一开始就避免需要变更版本。&lt;/p&gt;
&lt;p&gt;一个小型 API 负担不起为每个旧版本维护一条模块链，也不需要。借鉴那些承载价值的部分就够了：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;**带日期的版本。**日期不需要判断什么算&amp;quot;大版本&amp;quot;，调用方也读得懂。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-versioning-best-practices/&quot;&gt;API 版本控制最佳实践&lt;/a&gt;一文把它与 URL 和请求头方案做了对比。&lt;/li&gt;
&lt;li&gt;**固定的默认版本。**在首次使用时，把账户或密钥固定到某个版本，这样 API 永远不会在一个正常工作的集成之下发生变化。&lt;/li&gt;
&lt;li&gt;**单次请求覆盖。**一个请求头，让调用方可以在正式环境里，对一次调用测试新版本，然后再决定是否迁移。&lt;/li&gt;
&lt;li&gt;**webhook 端点上的版本。**事件负载是调用方最容易感到意外的地方。&lt;/li&gt;
&lt;li&gt;**每个版本一条体验日志条目。**让它写明版本、日期、谁受影响以及该做什么。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么才算破坏性变更&lt;/a&gt;是判断什么内容应该进入新版本的标准，而 &lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志&lt;/a&gt;一文则讲解了条目本身。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在受支持的版本数量迫使你这样做之前，先跳过模块链。两三个在线版本，用几个分支和一个停用日期就能处理，&lt;a href=&quot;https://changeloop.dev/blog/zh/sunsetting-api-version/&quot;&gt;停用一个 API 版本&lt;/a&gt;里有详细的演示。&lt;/p&gt;
&lt;p&gt;如果你发布了一份带日期的体验日志，版本历史的质量就取决于它的条目。在 &lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;Changeloop&lt;/a&gt; 中，每个已合并的 pull request 都会生成一条草稿条目，并留给人批准，之后才会发布到体验日志页面和信息流。每个版本的条目就是在这里写成的，而唯一一道人工关卡，就是那次说明调用方必须做什么的评审。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Stripe API 的最新版本是什么?&lt;/strong&gt;
截至本文撰写时（2026 年 10 月），Stripe 的版本控制页面声明当前版本是 &lt;code&gt;2026-09-30.endive&lt;/code&gt;。Stripe 每月发布一个新版本，所以固定版本之前请查看它的变更日志，并把版本写进你的代码，而不要依赖账户默认版本。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如何在请求上设置 Stripe API 版本?&lt;/strong&gt;
发送 &lt;code&gt;Stripe-Version&lt;/code&gt; 请求头，例如 &lt;code&gt;Stripe-Version: 2026-09-30.endive&lt;/code&gt;，或者在服务端 SDK 中全局设置版本或按请求设置版本。两者都没有时，请求会使用你账户的默认版本，它由你在 Workbench 中设置。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Webhook 使用的 Stripe API 版本与我的请求相同吗?&lt;/strong&gt;
不一定。Webhook 事件使用创建端点时设置的版本，如果没有设置，则使用账户默认版本。升级 SDK 并不会改变你的 webhook 处理程序收到的负载，所以要单独升级端点，并且并行测试它们。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stripe 式的日期版本控制适合小型 API 吗?&lt;/strong&gt;
带日期的版本、固定的默认版本、单次请求的请求头，以及每个版本一条体验日志条目，成本都很低，值得借鉴。内部的版本变更模块链则不适合，除非你要同时支持很多旧版本。可以先从两个在线版本和一个旧版本的停用日期开始。&lt;/p&gt;
</content:encoded></item><item><title>体验日志到底是谁在写，又应该是谁来写才对</title><link>https://changeloop.dev/blog/zh/changelog-entry-ownership/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/changelog-entry-ownership/</guid><description>谁来写体验日志？PR 作者清楚改了什么，产品经理清楚这对用户为什么重要，单靠任何一方都写不出客户真正能用的条目。</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;问一个团队是谁写体验日志，诚实的答案往往是&amp;quot;谁记得就谁写&amp;quot;，这和&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-ci-enforcement/&quot;&gt;在 CI 里强制要求体验日志
条目&lt;/a&gt;想在机械层面修复的，是同一种失败模式。但强制要求
条目存在，并不能决定谁有资格写出一条好条目，而跳过这个问题的团队，往往会默认交给最容易被
强制的人，通常是 PR 的作者，却从不核实这个人是不是真的能把它写好。&lt;/p&gt;
&lt;h2&gt;为什么 PR 的作者不会自动成为最好的体验日志作者&lt;/h2&gt;
&lt;p&gt;因为她了解实现细节，却不一定了解影响，而这是两种不同类型的知识。&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;Conventional commits
在哪里停下&lt;/a&gt;从提交信息这一侧讨论了这个鸿沟：
&lt;code&gt;fix(auth): reject expired refresh tokens&lt;/code&gt; 是准确的，但对客户来说什么都没说，而写下那个
修复的人，往往恰恰是最不适合翻译它的人，因为她已经用那个 bug 的视角思考了好几个小时，
失去了用户实际经历了什么的那种外部视角。这正是技术写作能够作为一种独立职业存在的原因：把实
现翻译成影响，是一种和造出这个东西本身完全不同的技能，不管一个开发者的代码水平有多好，这项
技能都需要专门练习才能掌握。&lt;/p&gt;
&lt;h2&gt;这是否意味着产品或支持团队应该代替开发者写每一条条目&lt;/h2&gt;
&lt;p&gt;不是，因为他们有相反的鸿沟：他们知道什么对用户重要，却不总是知道到底发布了什么，这会产生
读起来顺畅但在范围上时而出错的条目，比如给一个仍然藏在功能开关后面的功能贴上&amp;quot;现在已支持 X&amp;quot;
的说法，或者把只覆盖了三种情况中一种的修复描述成已经完成。开发者写的条目的失败模式是难读
但准确，PM 写的条目的失败模式是好读但未经核实。这两种角色都没有独自拥有一条好条目所需要
的两半。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;角色&lt;/th&gt;
&lt;th&gt;通常擅长&lt;/th&gt;
&lt;th&gt;通常出错&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;写代码的开发者&lt;/td&gt;
&lt;td&gt;变化的确切范围&lt;/td&gt;
&lt;td&gt;为没有参与开发的人做好框架呈现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PM 或支持负责人&lt;/td&gt;
&lt;td&gt;为什么这对用户重要&lt;/td&gt;
&lt;td&gt;到底发布了什么的精确边界&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;专职的体验日志负责人&lt;/td&gt;
&lt;td&gt;一致的语调、核对范围&lt;/td&gt;
&lt;td&gt;需要以上两者才有东西可核对&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;一个真正起作用的归属模型看起来是什么样的&lt;/h2&gt;
&lt;p&gt;由离变化最近的人写初稿，由离用户最近的人来审核，并由一个被指定的人对最终措辞负责，而不是
让每个人都以为别人会补上问题。比起写得好，初稿更需要的是存在并且准确；一句由开发者写的、
正确说清楚了变化的粗糙句子，是比打磨过但未经核实的句子更好的起点，因为为了清晰而重写，比
为了准确而重写要容易得多。审核这一步，就是 PM 或支持负责人读完初稿后问出那个唯一能抓住
可读性鸿沟的问题的地方：如果我没看过代码，我能理解这一条吗。&lt;/p&gt;
&lt;h2&gt;应该始终由同一个人负责，还是应该轮换&lt;/h2&gt;
&lt;p&gt;有指定的、稳定的人选胜过轮换，至少在最终批准这件事上是这样。轮换制的负责人意味着每一条条
目都由一个从零开始重新推导团队惯例的人来审核，这正是语调会随着条目漂移、读者开始察觉体验
日志是由一个委员会写出来的方式。一个人，或者一个非常小的稳定小组，会随着时间积累判断力：
什么时候该说&amp;quot;已改进&amp;quot;，什么时候该点出具体数字，什么时候一个修复需要单独一条条目，什么时候
该并入一批，而这种判断力比把工作平均分配更有价值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;初稿（开发者，来自 PR）：
&amp;quot;Fixed pagination cursor not respecting the `sort` param
in some edge cases.&amp;quot;

审核后（体验日志负责人，已对照真实 PR 核实）：
&amp;quot;已修复：按日期排序的导出结果，在超过第一页之后可能
返回顺序错乱的结果。现在所有页面都保持一致了。&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;一个小团队需要为一行文字投入这么多流程吗&lt;/h2&gt;
&lt;p&gt;不是把角色当成不同的人，但那两个步骤即便独自一人也依然重要。一个人的团队既是开发者也是
审核者，而在那种规模下能存活下来的纪律，是把审核当作一个独立的心理步骤来完成，而不是从写
修复直接一口气跳到发布对它的描述。小规模下的陷阱是干脆整个跳过第二遍审核，而不是缺少第二
个人，原因是没有任何外部力量强制要求，而那一遍存在的目的正是要抓住的那个准确性鸿沟，
并不会因为同一个人理论上可以自己察觉自己的盲点就凭空消失。&lt;/p&gt;
&lt;h2&gt;如果没有人对最终的条目负责，会发生什么&lt;/h2&gt;
&lt;p&gt;体验日志会不均匀地退化，而不是整体崩溃，这更糟糕，因为在读者指出之前没有人会注意到。有些
条目依然清晰锐利，因为写它们的人在乎；另一些则变得含糊，写成&amp;quot;若干改进和 bug 修复&amp;quot;，因为
写它们的人写得很快，而且没有人在发布前抓住这一点。&lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog&lt;/a&gt;
的格式约束，能抓住结构性的漂移、缺失的日期、错误的分类，但模板里没有任何东西能抓住一条
技术上格式正确、内容却含糊不清的条目。这恰恰是一个被指定的负责人存在要去填补的鸿沟。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;体验日志负责人应该是工程角色还是产品角色?&lt;/strong&gt;
只要这个人既有足够的技术能力核实范围，又和实现保持了足够的距离，能够为外部读者写作，两者
都可以胜任；头衔的重要性不如她能否同时做到这两半，或者她是否知道自己做不到的那一半该去问谁。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;类似值班轮换的排班方式，是否适合用来管理体验日志的归属?&lt;/strong&gt;
在工作量上有时可以，如果团队太小、一个人无法审核所有内容的话；但在语调和判断力上不行，因为
这恰恰是轮换会侵蚀的东西。一种在保留一位稳定审核者的同时分担初稿写作负担的轮换方式，可以在
不产生漂移的情况下获得那种好处。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;当前归属设置出了问题的最快信号是什么?&lt;/strong&gt;
条目呈现出准确但难读，或者好读但范围有误的模式，而这个模式跟随的是谁写了它。如果质量和作者
相关，而不是保持一致，那么问题出在归属上，而不是写作能力上。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自动化会降低归属这件事的重要性吗?&lt;/strong&gt;
它降低的是需要多少写作，而不是需要多少判断。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;
讨论了流水线可以安全生成的部分：格式化、发布、跨平台同步；措辞、分组，以及什么才算值得一提，
无论流水线自动化了多少，这些始终是人的决定。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果 PR 的作者和审核者在措辞上有分歧怎么办?&lt;/strong&gt;
以审核者的判断为准，因为她要回答的那个问题，一个外部读者能不能理解这一条，正是这个角色存
在的意义所在。这并不代表开发者的判断毫无价值：如果分歧其实是关于准确性而不是措辞本身，审
核者就应该让步，因为范围是否正确是作者那一半该负责的事。把这两种分歧，措辞和准确性，区分
开来，大多数争执就不会演变成僵局。&lt;/p&gt;
</content:encoded></item><item><title>紧急发布说明：如何在真实的时间压力下动笔写作</title><link>https://changeloop.dev/blog/zh/emergency-release-notes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/emergency-release-notes/</guid><description>严重事故触发的紧急发布，要在几分钟内写出可用且诚实的发布说明，而通常的写作流程默认写作者有从容的时间。本文说明在时间压力下，如何面对焦虑的读者动笔。</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;大多数发布说明都是在代码完成之后写的，从容地经过审查，并按照一个和任何人有多迫切需要读到它
毫无关系的时间表发布。紧急发布、安全补丁、数据丢失的 bug、故障修复，会同时把所有这些条件都
颠倒过来：说明必须在大多数人通常才刚开始动笔之前就存在，几乎得不到审查，而且读它的是焦虑的
读者，不是从容的读者。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;如何写发布说明&lt;/a&gt;讨论的是正常
流程；这篇讨论的是当没有时间遵循它时，情况会有什么变化。&lt;/p&gt;
&lt;h2&gt;即便别的都做不到，紧急发布说明必须做对的那一件事是什么&lt;/h2&gt;
&lt;p&gt;在没有任何前置铺垫的情况下，用第一句话说清楚读者是否需要采取行动。一个遇到由事故触发的说明
的读者，往往已经因为从状态页面、支持工单或者自己的用户那里听说了这个问题而感到担忧。在行动
项之前先给出背景的说明，读起来就像是在恰恰最不该保留信息的情况下保留了信息。&amp;quot;无需采取任何
行动，这修补了一个不需要用户数据就能被利用的漏洞&amp;quot;和&amp;quot;请立即更新：这次发布修复了一个可能把
一个账户的数据展示给另一个账户的 bug&amp;quot;，两句都只有一句话，也都在焦虑的读者读到别的任何内容
之前，完成了她需要的全部工作。&lt;/p&gt;
&lt;h2&gt;当没有时间去做时，平常的编辑还适用吗&lt;/h2&gt;
&lt;p&gt;压缩的本能会一直存在，即使通常产生这种压缩的多轮草稿流程并不存在。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;重写&lt;/a&gt;
描述的是把一份啰嗦的初稿削减到它的本质；在时间压力下，往往根本没有初稿可供削减，这意味着这
种纪律必须在写作过程中就在脑子里运作，而不是作为之后的一个独立步骤。最快的方法是：写下你会
对一个问&amp;quot;我需要知道什么&amp;quot;的人大声说出来的那句话，然后停笔，因为那句话通常既是最快能写出来
的，也是那个状态下的读者真正会去处理的唯一一句。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;常规发布说明&lt;/th&gt;
&lt;th&gt;紧急发布说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;在代码审查之后、发布之前写成&lt;/td&gt;
&lt;td&gt;常常和修复同步写成，在完整审查之前&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;为在多条目中快速浏览而优化&lt;/td&gt;
&lt;td&gt;为在压力下被单独阅读的一条目而优化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可以把细节留给关联的体验日志&lt;/td&gt;
&lt;td&gt;必须把最重要的事实放在最前面&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;铺垫和背景是受欢迎的&lt;/td&gt;
&lt;td&gt;行动项之前的铺垫读起来像是在拖延&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;在真正确定问题原因之前发布一份说明，有时候可以吗&lt;/h2&gt;
&lt;p&gt;可以，前提是这份说明对那种不确定性诚实，而不是暗示一种你并不拥有的确定感。&amp;quot;我们已经为结账
环节错误率上升部署了一个修复；我们仍在确认根本原因，并会更新这份说明&amp;quot;是站得住脚的，也正确地
争取了时间；一份声称了某个你实际上并未确认过的具体原因的说明，是那种如果后来证明错了，人们
会反过来引用给你看的猜测。这里重要的纪律不是诊断的速度，而是永远不让说明的确定感超过团队真
实的确定感，因为紧急说明里一个错误的技术性断言，比承认的无知更损害信任。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;过度自信、未经验证:
&amp;quot;Fixed: a race condition in the payment webhook handler
caused duplicate charges.&amp;quot;

在时间压力下的诚实:
&amp;quot;已修复：部分客户曾就同一笔订单被重复扣款两次。我们
已经阻止了新的发生，并在 24 小时内为受影响的账户
退款。正在调查根本原因。&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;紧急说明应该提到问题的原因吗，还是只说它已经被修复了&lt;/h2&gt;
&lt;p&gt;说清楚修复了什么，以及读者应该做什么；把根本原因留给一份后续说明，等它真正被查明，而不是被
猜测出来的时候再写。身处事故当中的读者恰好想要两个事实：这件事是否已经解决，以及这件事是否
影响到我，而根本原因的解释，即便是准确的，也会在最不该失去这两个事实和注意力的时刻和它们
争夺注意力。事后分析报告在调查完成后单独发布，那才是根本原因该出现的地方；在时间压力下把
这两份文档混在一起，只会产生一份写得更慢、读起来也更慢的说明，恰恰是紧急情况所需要的东西的
反面。&lt;/p&gt;
&lt;h2&gt;移动应用强制更新的问题在这里也适用吗&lt;/h2&gt;
&lt;p&gt;同样的原则适用，只是被压缩得更厉害。&lt;a href=&quot;https://changeloop.dev/blog/zh/mobile-app-release-notes/&quot;&gt;移动应用的发布说明&lt;/a&gt;
讨论了强制更新，那里说明必须在别的任何内容之前先说明理由和截止日期，因为读者已经因为没有
选择而感到恼火了。网页上的紧急说明通常对读者来说是可选的，意思是她可以选择是否据此采取行动，
但同样的&amp;quot;先说明约束条件&amp;quot;的本能仍然适用，只是原因不同：不是恼火，而是紧迫感。&lt;/p&gt;
&lt;h2&gt;如何避免让紧急说明读起来像是在承认过错，尽管它不应该是这样&lt;/h2&gt;
&lt;p&gt;描述修复及其效果，而不是错误本身，并克制过度道歉的冲动，那对想要上面那两个事实的读者来说
读起来就像是废话。&amp;quot;我们发现并修复了一个影响部分导出功能的 bug&amp;quot;陈述了发生了什么，而没有给
它添加戏剧性；&amp;quot;我们对给尊贵客户带来的这个严重问题深表歉意&amp;quot;把有用的信息整整推迟了一句话，
只为了传达一个读者并没有要求的情感时刻。简洁而基于事实的说明并不冷漠，它是对读者真实状态
的尊重，而在真正的压力之下，那种状态是不耐烦，而不是需要被安抚。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;紧急发布说明应该经历和常规说明一样的审查流程吗?&lt;/strong&gt;
应该是更轻量的，而不是完全没有：一位快速审查者，确认说明没有夸大确定性，这值得花上它所需
的那几分钟，因为一个未经审查的技术性断言出错的风险更高，恰恰是因为它写得很快。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在没有链接到更多细节的情况下发布一份紧急说明可以吗?&lt;/strong&gt;
只能暂时如此。没有链接的说明作为最先发布的内容是可以的；一旦状态页面或后续说明中有任何一
个存在，就添加一个链接过去，因为想要比你给出的那一句话更多信息的读者，需要一个可以去的地方，
即便那个地方只是说&amp;quot;更多细节即将发布&amp;quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;紧急说明是否有时应该完全被省略，让修复悄悄地上线?&lt;/strong&gt;
只在没有任何读者能够注意到或受到影响的问题上才可以这样做；如果有可能读者曾经历过这个问题，
说明就是告诉她这件事已经结束的东西，而沉默读起来就像这个问题可能仍然是活跃的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;事故解决之后，紧急说明应该被置顶或保持显眼多久?&lt;/strong&gt;
直到那种即时的焦虑窗口关闭为止，通常是一两天，之后它就可以像其他任何条目一样折叠进常规的
体验日志里；一份置顶了好几个星期的说明，开始读起来像是一个尚未解决的担忧，而不是已经解决的。&lt;/p&gt;
</content:encoded></item><item><title>Protobuf 破坏性变更：线上格式到底能保住什么</title><link>https://changeloop.dev/blog/zh/grpc-protobuf-api-changes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/grpc-protobuf-api-changes/</guid><description>Protobuf 的破坏性变更发生在线上传输的字节流里，而不是 URL 路径。有些字段改动安全免费，有些会悄悄破坏所有老客户端，在代码 diff 里却几乎一样。</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;REST API 会在 JSON 的形状变化时发生变化，而这个形状的大部分内容都能在浏览器里读到的响应中看
见。gRPC API 会在 &lt;code&gt;.proto&lt;/code&gt; 文件变化时发生变化，而 Protocol Buffers 的二进制线上格式，对于
客户端能承受什么，有一套自己的规则，这套规则和字段名说了什么完全没有关系。两个在 diff 里看
起来同样微不足道的改动，重新编号一个字段和添加一个新字段，落在&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;破坏性变更&lt;/a&gt;
通常划出的那条线的两侧：一个对每一个现有客户端都不可见，另一个会同时破坏它们全部。要把 protobuf 里真正的破坏性变更
和安全的改动区分开，靠的是去读线上格式自己的规则，而不是靠猜测这个改动在 &lt;code&gt;.proto&lt;/code&gt; diff 里看
起来怎么样。&lt;/p&gt;
&lt;h2&gt;为什么在 Protobuf 里字段编号比字段名更重要&lt;/h2&gt;
&lt;p&gt;因为线上格式是按编号而不是按名字来编码字段的。每种语言生成的代码都读写这些编号；&lt;code&gt;.proto&lt;/code&gt;
文件里 &lt;code&gt;email&lt;/code&gt; 这个字段名只是给人看的便利，从来不会碰到在网络上传输的二进制字节。重命名一个
字段，把 &lt;code&gt;email&lt;/code&gt; 改成 &lt;code&gt;email_address&lt;/code&gt;，只要编号保持不变，在二进制线上格式里就是安全的，这会让习惯了
REST 的工程师感到惊讶，因为在 REST 里，重命名的 JSON 键恰恰就是那种会破坏客户端的改动。例外恰恰就是 REST 那种情况：&lt;a href=&quot;https://protobuf.dev/programming-guides/json/&quot;&gt;ProtoJSON 和文本格式&lt;/a&gt;
会序列化字段名，所以重命名会破坏 JSON 转码（例如 grpc-gateway）、文本格式文件和字段掩码。给
同一个字段重新编号，保持名字不变但把 &lt;code&gt;1&lt;/code&gt; 改成 &lt;code&gt;7&lt;/code&gt;，则恰恰相反：在只显示名字的代码审查里
不可见，却会破坏客户端从那一刻起发送或接收的每一条消息。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;th&gt;在线上是否安全&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;重命名字段，保留编号&lt;/td&gt;
&lt;td&gt;二进制是，JSON 和文本否&lt;/td&gt;
&lt;td&gt;二进制编码使用的是编号；ProtoJSON 和文本格式使用的是名字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;更改字段编号&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;每一条现有消息现在都会被读成另一个字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用新编号添加新字段&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;旧客户端会忽略它们不认识的字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;删除一个字段，把它的旧编号重新用于别的东西&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;旧数据会被解码进错误的新字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;不兼容地更改字段类型（例如把 &lt;code&gt;int32&lt;/code&gt; 改成 &lt;code&gt;string&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;不同类型的线上编码方式不同&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;为什么删除字段和在 REST JSON 响应里做同样的事不一样&lt;/h2&gt;
&lt;p&gt;因为编号会变得带有放射性。&lt;a href=&quot;https://protobuf.dev/programming-guides/proto3/&quot;&gt;Protobuf 自己的指引&lt;/a&gt;建议把已删除字段的编号标记为 &lt;code&gt;reserved&lt;/code&gt;，
而不是允许它被重新使用，因为真正的损害恰恰发生在重用那一刻：一个仍然运行着几个月前生成代码
的客户端，为一个旧值发送了旧的字段编号，而服务端现在期望那个编号表示别的东西，于是它没有直接
拒绝数据，而是悄悄地把数据解释错了。REST 没有与之对应的陷阱，因为被删除的 JSON 键只是不再
出现而已；没有任何方式能让旧客户端的请求被悄悄重新解释成别的东西。消息开头带有
&lt;code&gt;reserved 4, 9, 12;&lt;/code&gt; 的 &lt;code&gt;.proto&lt;/code&gt; 文件是一道永久的伤疤，而这正是它的意义所在：它阻止那个编号
被不了解其历史的人交给一个新字段。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-protobuf&quot;&gt;message Invoice {
  reserved 4; // 曾经是 `legacy_customer_id`，于 2026-06-01 删除
  reserved &amp;quot;legacy_customer_id&amp;quot;; // 名字也保留，为了 JSON/文本格式
  string customer_id = 5;
  string status = 6;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;添加字段本身需要一条体验日志条目吗&lt;/h2&gt;
&lt;p&gt;通常不需要作为破坏性变更的条目，但往往需要一条普通条目，因为&amp;quot;在线上安全&amp;quot;和&amp;quot;对在意的读者
可见&amp;quot;是两个不同的主张。给响应消息添加一个字段在结构上是免费的，旧客户端解码消息时会自动
忽略新字段。但如果没有人告诉她，正在针对这项服务构建新集成的人根本无从得知这个字段的存在，
因为无论是成功的构建还是通过的测试，都不会让一个新的可选字段变得可见。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;体验日志 API&lt;/a&gt;
一般性地讨论了增量条目对读者的义务；而 gRPC 特有的原因是，没有任何等价的东西能让人在调试器
里浏览 REST 响应时注意到一个新键的出现。&lt;/p&gt;
&lt;h2&gt;这和 GraphQL 调用方面对的情况有什么不同&lt;/h2&gt;
&lt;p&gt;关于新增字段的规则是一样的，但暴露面不同。&lt;a href=&quot;https://changeloop.dev/blog/zh/graphql-schema-deprecation/&quot;&gt;GraphQL 模式弃用&lt;/a&gt;讨论了一种
模型，客户端只会收到自己明确请求的字段，这让增量变化本质上没有风险，只有删除才是真正的
危险。相比之下，gRPC 客户端会收到服务端发送的一切内容，并针对自己编译好的模式副本解码
全部内容；客户端的暴露程度不是由它请求了什么决定的，而是仅由它生成的代码能读懂什么决定的。
这个区别在写体验日志时很重要：GraphQL 的条目可以合理地假设客户端受到保护，不会受到它们没
请求的字段影响，而 gRPC 的条目完全不能做这种假设。&lt;/p&gt;
&lt;h2&gt;gRPC 服务的版本控制和 REST 的 &lt;code&gt;/v1/&lt;/code&gt;、&lt;code&gt;/v2/&lt;/code&gt; 工作方式一样吗&lt;/h2&gt;
&lt;p&gt;意图相同，机制不同。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-versioning-best-practices/&quot;&gt;REST API 中的 v1 和 v2 是什么&lt;/a&gt;
把版本控制当作提供不同契约的并行 URL 路径来处理；gRPC 服务通常是通过 &lt;code&gt;.proto&lt;/code&gt; 文件本身内部
的包名来做版本控制的，&lt;code&gt;payments.v1.InvoiceService&lt;/code&gt; 会变成 &lt;code&gt;payments.v2.InvoiceService&lt;/code&gt;，
这改变的是客户端拨号的完全限定服务名，而不是它请求的 URL 段。两种方法解决的是同一个问题：
让新契约存在的同时，旧契约继续工作。但有着 REST 背景的团队常常在错误的地方寻找版本号，
从而错过了这项工作是由包声明来完成的这一点。&lt;/p&gt;
&lt;h2&gt;gRPC 的体验日志条目实际上应该点名什么&lt;/h2&gt;
&lt;p&gt;消息、字段编号，以及这个改动是增量的还是需要迁移的删除，这就是对决定是否要采取行动的读者来说
重要性的顺序。&amp;quot;在 &lt;code&gt;Order&lt;/code&gt; 中添加了 &lt;code&gt;shipping_address&lt;/code&gt;（字段 8）&amp;quot;告诉集成者更新生成代码并
开始使用它所需的一切。&amp;quot;保留了 &lt;code&gt;Invoice&lt;/code&gt; 中的字段 4，&lt;code&gt;legacy_customer_id&lt;/code&gt; 已消失&amp;quot;告诉她去
检查自己代码库里有没有什么东西还在读那个字段，这是 REST 风格的&amp;quot;从响应中删除了字段&amp;quot;这条注记
无法传达出的同等紧迫性，因为 REST 的删除只是返回更少的数据，而 Protobuf 的字段重用会主动
破坏数据。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;字段类型能否在不破坏线上格式的情况下被更改?&lt;/strong&gt;
只能在 Protobuf 文档记录的特定兼容组内更改，比如在某些情况下把 &lt;code&gt;int32&lt;/code&gt; 扩展成 &lt;code&gt;int64&lt;/code&gt;。
除非你已经对照 Protobuf 自己的兼容性表核实过，否则把任何类型更改都当作破坏性变更来对待；
按语言类型系统的类比来假设兼容性，正是出问题的方式。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Protobuf 里的字段弃用工作方式和 GraphQL 的 &lt;code&gt;@deprecated&lt;/code&gt; 指令一样吗?&lt;/strong&gt;
类似。Protobuf 支持工具可以显示的字段选项 &lt;code&gt;[deprecated = true]&lt;/code&gt;。两者都不是强制的：
GraphQL 服务端仍然会响应对已弃用字段的查询，protobuf 客户端也仍然会对它进行编码。两者都只是
建议性的，都需要同样的体验日志支持。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果你控制着每一个客户端，重新编号是否安全?&lt;/strong&gt;
在一个完全封闭的系统里，原则上是安全的，但这会消除字段编号存在的整个安全属性，而&amp;quot;我们控制
着每一个客户端&amp;quot;这句话，会在构建被缓存、部署被延迟、或者添加了没人记得的客户端的那一刻起就
不再成立。即使在公司内部，也要保留编号而不是重新使用它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;gRPC 服务是否需要像公开的 REST API 一样有一个体验日志页面?&lt;/strong&gt;
只有当外部团队消费它、而不是直接阅读 &lt;code&gt;.proto&lt;/code&gt; 的 diff 时才需要，这和&lt;a href=&quot;https://changeloop.dev/blog/zh/internal-api-changelog/&quot;&gt;内部 API 体验
日志&lt;/a&gt;一般性适用的&amp;quot;对方是谁&amp;quot;这个测试是一样的。一个只被
同一团队的其他服务消费的 gRPC 服务，往往可以不用正式的体验日志，靠提交历史就够了，因为
任何阅读它的人本来就已经打开了那份模式定义。&lt;/p&gt;
</content:encoded></item><item><title>体验日志文件格式：JSON、YAML，还是干脆用 Markdown</title><link>https://changeloop.dev/blog/zh/changelog-file-formats/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/changelog-file-formats/</guid><description>体验日志的文件格式，决定它能否驱动页面和小组件，还是只能被人读一遍。本文讲 Markdown、JSON 和 YAML 各自的代价，以及何时值得迁移到结构化格式。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;大多数团队把体验日志起步成一份 Markdown 文件，因为这是阻力最小的一条路：在 pull request 的 diff 里能读，在 GitHub 上不用渲染任何东西就能读，而且对任何写过 README 的人来说都很眼熟。这个选择运作得很好，一直好到有什么人以外的东西需要读这份文件为止，一个页面、一个小组件、一封邮件摘要，从那一刻起，这个格式就不再是免费的了。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;一般性地讲了结构化的要求，类型、日期、正文和链接；而这篇讲的是哪种文件格式真正能交付出那种结构，以及每一种格式为了到达那里各自要付出多少代价。&lt;/p&gt;
&lt;h2&gt;一份普通的 Markdown 体验日志到底有什么问题&lt;/h2&gt;
&lt;p&gt;没什么问题，直到有什么东西需要把它反解析回字段为止。一个标题、一个日期，加上下面一个项目符号列表，对人来说读起来毫不费力，但要可靠地解析它却真的很难，因为 Markdown 根本没有模式：日期可能在标题里，可能加粗放在第一行，也可能在一条旧记录里干脆就不存在，而这些变体的每一种，都是人能正确读出来、解析器却读不出来的合法 Markdown。自动化 Markdown 体验日志的团队，通常最后都会写出一个基于正则表达式的自制解析器，只要某条记录的格式稍微偏离一点点就会崩掉，而这种情况经常发生，因为写的时候没有任何东西在强制保持一致。&lt;/p&gt;
&lt;h2&gt;一种结构化格式到底能给你带来什么&lt;/h2&gt;
&lt;p&gt;一份保证，保证每条记录都有同样的形状，而这份保证是在记录被写下的那一刻就被检查过的，而不是在被读取的时候被人猜出来的。一份定义了模式的 JSON 或 YAML 文件，类型、日期、版本、受众、正文、链接，一旦缺了某个必填字段就会大声报错失败，就跟一个严格的 API 响应会做的一样；而一份 Markdown 文件只会把那里有的东西原样渲染出来，不管它对不对。这种差别是看不见的，一直到某天一个脚本需要每条记录的日期去给一条信息流排序，却发现一半的记录把日期放在了别的地方。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# CHANGELOG.yml
- date: 2026-09-05
  type: breaking
  version: v2
  audience: api
  body: &amp;quot;POST /invoices now rejects a currency mismatch instead of silently converting.&amp;quot;
  link: /blog/api-changelog/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;这是不是说那份人类可读的文件必须消失&lt;/h2&gt;
&lt;p&gt;不是，而且试图让一份 YAML 或 JSON 文件同时兼职扮演人在 pull request 里读的那份东西，通常是在反方向上犯了个错误：审阅一段嵌套 JSON 的 diff，比审阅一句散文要糟糕得多，而一个必须在脑子里把数据结构解析一遍才能抓出一个措辞错误的审阅者，终究会是一个渐渐不再去抓措辞错误的审阅者。这两种格式完全可以共存：结构化数据是自动化流水线读取的那个真相来源，而一份生成出来的 Markdown 或 HTML 渲染，才是人真正去审阅、去读的东西，它是从那份结构化文件产生出来的，而不是手动在旁边另外维护的。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;格式&lt;/th&gt;
&lt;th&gt;原样是否人类可读&lt;/th&gt;
&lt;th&gt;不写自定义代码能否被机器解析&lt;/th&gt;
&lt;th&gt;常见的失败方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;能&lt;/td&gt;
&lt;td&gt;不能&lt;/td&gt;
&lt;td&gt;不一致的记录形状会弄坏那些朴素的解析器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;差&lt;/td&gt;
&lt;td&gt;能&lt;/td&gt;
&lt;td&gt;啰嗦；手动编辑很容易改出一份无效的 JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML&lt;/td&gt;
&lt;td&gt;还行&lt;/td&gt;
&lt;td&gt;能&lt;/td&gt;
&lt;td&gt;对空白很敏感；一个缩进错误是一次安静的解析错误，而不是一次响亮的&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;哪种结构化格式手动编辑起来其实更容易，JSON 还是 YAML&lt;/h2&gt;
&lt;p&gt;是 YAML，对任何手写记录而不是通过一个生成器去写的人来说都是。因为它省掉了 JSON 对每一个字符串和每一个嵌套对象都要求的引号和括号匹配。代价在于，YAML 对空白的敏感性会以一种 JSON 括号不匹配通常不会出现的方式安静地失败：一个 JSON 解析器会直接拒绝格式错误的输入，而一个 YAML 解析器却可能接受一份缩进错了的文件，然后就那么把它解析成了错误的结构，这是一种更糟的失败，因为没有任何东西会告诉你这件事发生了。如果记录永远只由一个脚本来写，这种代价基本上就消失了，JSON 更严格的解析方式也就变成了更安全的默认选择。&lt;/p&gt;
&lt;h2&gt;一个体验日志页面是不是需要一种自己专属的结构化格式，和喂养它的那份文件分开&lt;/h2&gt;
&lt;p&gt;不需要一种分开的格式，而是同一份数据被渲染成了不同的样子。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-page/&quot;&gt;体验日志页面&lt;/a&gt;讲的是怎样通过一条 JSON feed 和 schema.org 标记，让页面本身变得机器可读；那条 feed 是生成出来的输出，而不是一个需要和底层文件保持同步的第二个真相来源。在两个地方手动维护结构化数据，一份源文件和一个页面的 feed，正是这两者最终走散的原因，所以这里做出的文件格式决定，应该成为唯一那份东西，让下游的一切，页面、小组件、邮件，都是从它生成出来的，而不是被手动复制过去的。&lt;/p&gt;
&lt;h2&gt;把一份既有的 Markdown 体验日志转换成结构化格式，这个迁移成本值不值得&lt;/h2&gt;
&lt;p&gt;通常只有当自动化真的成为目标之后才值得，在此之前不值得。一个把 Markdown 文件发布在 GitHub README 里的单人项目，并没有真正的自动化需求，把它转成 YAML 除了买来一堆繁文缛节之外什么都得不到。这个转换会在超过一个下游消费者，一个页面、一封摘要邮件、一条公开的 feed，需要读取同一份数据的那一刻开始自己收回成本，因为那正是 Markdown 解析器的不一致性开始产出肉眼可见的错误输出，而不只是维护起来烦人的那个临界点。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;一份 Markdown 体验日志能不能在不彻底换格式的前提下变得可解析?&lt;/strong&gt;
可以部分做到，靠 frontmatter：在每条记录开头放一小块 YAML（日期、类型、版本），旁边跟着一段用于散文的 Markdown 正文。这样能拿到解析器需要的那些结构化字段，又不用把整条记录都硬塞进 JSON 或 YAML，对一个还没准备好做完整迁移的团队来说，是个合理的中间点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;文件格式对 SEO，或者对一个体验日志页面的排名，到底有没有影响?&lt;/strong&gt;
没有直接影响。搜索引擎读的是渲染出来的页面，不是源文件，所以文件格式对它们来说是不可见的；真正对页面本身重要的，是它自己是不是机器可读，这和是什么生成了它，完全是两个不同的问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每一条体验日志记录是不是都应该经过同一份文件，还是可以按类型拆到多份文件里?&lt;/strong&gt;
一份文件更简单，直到记录量大到让 diff 或审阅变得不方便为止；按年份或按类别拆分，是一个合理的泄压阀，一旦单一文件的 diff 大到没法合理审阅的地步就可以用，但这会在下游的任何东西能把「所有记录」当成一份列表读取之前，多加一个合并的步骤。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;有没有一种像 RSS 那样存在标准的体验日志文件格式?&lt;/strong&gt;
没有一种被广泛采纳的。Keep a Changelog 提出了一套 Markdown 约定，还有好几个工具都有自己的格式；一个 &lt;a href=&quot;https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md&quot;&gt;changeset&lt;/a&gt; 就是一份带 YAML frontmatter 的 Markdown 文件，写明对应的包和版本升级幅度，也就是上面讲过的 frontmatter 模式。但这些都不是那种别的工具能开箱即用直接读取的格式，不像 RSS 阅读器普遍都能理解 RSS 那样。&lt;/p&gt;
</content:encoded></item><item><title>重复的功能请求：怎么合并但不丢失原来的声音</title><link>https://changeloop.dev/blog/zh/duplicate-feature-requests/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/duplicate-feature-requests/</guid><description>合并重复的功能请求能保住数字，但粗心合并会丢掉让某条请求真正有用的措辞。本文讲保留措辞的合并流程、如何识别误判、是否通知提出者，以及功劳记给谁。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;三位客户在三个不同的星期，用三种不同的说法，请求了同一种能力，而一套为了拦下重复请求而设计的分诊流程做好了自己的本职工作：把它们分到一组，算作一条带着三票的请求，待办清单也保持干净。这是容易的那部分。&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;哪些标签才真正值得打&lt;/a&gt;讲的是，在按措辞分诊之前先按底层能力分组，把这当作应对重复请求的机械式修复；它没讲到的是，一旦三条请求变成了一行，那些话语本身会发生什么，而这份损失通常比它解决掉的那个&amp;quot;重复计数&amp;quot;问题要大得多。&lt;/p&gt;
&lt;h2&gt;重复请求被合并的时候，到底会真正丢掉什么&lt;/h2&gt;
&lt;p&gt;丢掉的是每一位提出请求的人各自使用的具体措辞，而这份措辞往往比它最终坍缩进去的那个票数更有信息量。一位客户可能要求&amp;quot;一种导出经过筛选的结果的方法&amp;quot;，另一位要求&amp;quot;尊重我保存过的筛选条件的 CSV 导出&amp;quot;，第三位要求&amp;quot;不包含隐藏列的导出&amp;quot;。这三条其实是同一条底层请求，被正确地分到了一组，但每一种措辞都对那个人真正在意的东西带着一点微妙不同的侧重，而一次只保留第一份提交措辞的合并，会把另外两份彻底扔掉。数字活下来了；而能帮助某人把这个功能的正确版本做出来的那种质感，没有活下来。&lt;/p&gt;
&lt;h2&gt;如果票数已经说明需求存在，为什么这种质感还重要&lt;/h2&gt;
&lt;p&gt;因为需求和设计是两个不同的问题，而只有具体的措辞才能回答第二个问题。&amp;quot;导出&amp;quot;上的十票能告诉一个团队这个功能值得去做；但它完全没说&amp;quot;导出&amp;quot;到底是指 CSV、PDF、一封定时邮件，还是一个 API 端点，而一次为了第一份措辞而扔掉十份原始提交中九份的合并，可能会悄悄把这个规格收窄成第一位提出请求的人碰巧问到的那个东西，哪怕另外九个人想要的是某种微妙不同的东西。&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;一条功能请求真正应该记录下来的是什么&lt;/a&gt;从接收端讲的正是同一个缺口；而合并重复请求，正是这个缺口在接收之后重新冒出来的地方，恰恰是一个团队最需要了解人们到底真正问过什么的那个范围的那一刻。&lt;/p&gt;
&lt;h2&gt;一套保留措辞而不是把它扔掉的合并流程，看起来是什么样子&lt;/h2&gt;
&lt;p&gt;追加，而不是替换。那条规范性的条目在待办清单视图里只保留一个标题，但每一份被合并进来的提交的原始措辞，都会作为一份引文清单，或者作为几个被链接起来的源工单，继续附着在它上面，这样任何一个之后来复查这条条目的人，都能看到人们真正问过什么的那个实际范围，而不是团队里某一个人写的摘要。这样做几乎不花什么成本，只是工单上的一个字段，而不是一套新系统，而这正是压缩信息的合并，和只压缩了信息展示方式的合并之间的区别。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;功能：经过筛选的 CSV 导出
票数：12
被合并的请求：
  - &amp;quot;一种导出经过筛选的结果的方法&amp;quot; (acct_4421)
  - &amp;quot;尊重我保存过的筛选条件的 CSV 导出&amp;quot; (acct_8832)
  - &amp;quot;不包含隐藏列的导出&amp;quot; (acct_1097)
  ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;是不是每一条重复请求都值得合并，还是也存在误判的匹配&lt;/h2&gt;
&lt;p&gt;有一些确实是误判的匹配，而把&amp;quot;听起来相似&amp;quot;当成&amp;quot;是同一条请求&amp;quot;，本身就是一种独立的失败方式。&amp;quot;让我导出我的数据&amp;quot;和&amp;quot;只让我导出经过筛选的那个视图&amp;quot;，可能会因为一次落在&amp;quot;导出&amp;quot;这个关键词上的匹配而被分到同一组，尽管它们实际描述的是同一种通用能力下两个不同的范围；合并它们，要么会把票数灌到错误的东西上，要么更糟，会因为其中一条碰巧先到达，就把那个范围更窄的版本直接发布出去。一次人工过一遍分组，哪怕是快速的，也能在这种偏差累积起来之前把它拦下来；而单靠自动化的相似度匹配，会在词汇层面过度合并，在意图层面合并不足。&lt;/p&gt;
&lt;h2&gt;查重到底应该什么时候进行，是在收到请求的那一刻，还是留到以后&lt;/h2&gt;
&lt;p&gt;两个时机都需要，各有各的道理。在收到请求的那一刻就检查，能抓住那种最明显的情况：一条新请求
只是在重复一件已经开着的事情，在它变成一个独立的、没人追踪的条目之前就把它拦下来；在提交时对
所有开着的请求做一次相似度搜索，不需要任何人插手，就能处理掉这类情况里的大多数。晚一点、以一
个更慢的节奏再做的第二遍检查，抓的是收件时那一遍会漏掉的情况：两条请求当初用的措辞差异足够
大，足以在关键词或者向量匹配那一关侥幸溜过去，但等一个团队看过十几种不同的说法之后，才发现它
们描述的其实是同一种底层能力。跳过这第二遍检查，会让这些近似重复的请求，无限期地散落在各自独
立的标题之下，每一条都只带着自己那一小撮票数，永远也凑不到能真正推动它被开发出来的那个数字。&lt;/p&gt;
&lt;h2&gt;提出请求的人应不应该知道自己的提交被合并进了一条已有的条目&lt;/h2&gt;
&lt;p&gt;应该，而这和&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭合客户反馈循环&lt;/a&gt;遵循的是同一种纪律，只不过应用得比平常更早一步：一个提交了什么、之后再也没听到任何回音的提出请求的人，会认定自己的请求没有走向任何地方，哪怕它其实已经被正确地合并进了一条带着另外十一票、最终确实发布出去的条目里。一句简短的确认，&amp;quot;我们已经把这条和其他人也提过的一条已有请求合并在了一起&amp;quot;，只需要一条消息的成本，就能防止一位客户因为完全看不出这条请求到底有没有被真正追踪过，而每隔几个月就重新提交一次同样的请求。&lt;/p&gt;
&lt;h2&gt;合并会不会改变功能发布时功劳该记在谁头上&lt;/h2&gt;
&lt;p&gt;它应该把所有人都包括进去，而不只是最先提交的那个人。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭合反馈循环&lt;/a&gt;讲的是在一个请求发布时通知提出请求的人；而对一条被合并的条目来说，这意味着每一个附着在这次合并上的账户，而不只是那个措辞最终变成规范标题的账户，因为从每一位提出请求的人自己的角度看，她确实问过这件事，而它也确实发布了，无论一套分诊流程碰巧保留了谁的措辞。在 Changeloop 中，这意味着 pull request 要点名每一个关联的 issue（&lt;code&gt;Fixes #142, fixes #187&lt;/code&gt;）；没被点名的 issue 不会收到评论。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;每条被合并的请求，值得保留多少措辞，是一句引文，还是一条完整的工单链接?&lt;/strong&gt;
一句简短的引文通常在常见情况下就够了，因为它的目的只是让复查的人能一眼看到措辞的范围；但当原始提交带着重要的额外背景信息时，比如一张截图，或者一段一句引文会把它压平掉的详细工作流描述，也要把完整的工单链接一并保留下来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;保留每一条重复请求的措辞，会不会让待办清单变得更难扫读?&lt;/strong&gt;
不会，如果它默认是折叠起来的。规范标题是一位快速扫读的复查者会看到的东西；被合并进来的措辞只隔着一次点击或一次展开，为做更深入研究的人而存在，但不会挤占那个只是在数票数的人的视野。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果两条请求看起来完全一样，但真正做出来之后才发现想要的是不同的东西，该怎么办?&lt;/strong&gt;
一旦这一点变得清楚，就把它们重新拆开，并且把最初的那次合并当成一个在当时可用信息下做出的合理决定，而不是一个要避免重犯的错误。一套从不拆分任何东西的分组系统，最终总会有几次错误的合并被永久固化下来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;是不是存在一个票数门槛，超过之后被合并的请求就应该获得一次对底层措辞的人工复查?&lt;/strong&gt;
没有一个固定的数字，但任何一条正在接近开发决策的请求，都值得获得这种复查，无论票数多少，因为那正是&amp;quot;导出&amp;quot;和&amp;quot;带着保存过的筛选条件、以 CSV 形式导出&amp;quot;之间的差别，不再只是一种细微差异、而开始变成规格本身的那个节点。&lt;/p&gt;
</content:encoded></item><item><title>GraphQL 模式变更：没有版本号的停用</title><link>https://changeloop.dev/blog/zh/graphql-schema-deprecation/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/graphql-schema-deprecation/</guid><description>GraphQL 的 URL 里没有 v1 或 v2，字段通过指令在同一份共享模式上逐个停用。本文说明这会如何改变体验日志要给调用方交代的内容。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;REST API 可以在 &lt;code&gt;/v1/&lt;/code&gt; 旁边发布一个 &lt;code&gt;/v2/&lt;/code&gt;，让每个调用方按自己的节奏迁移。GraphQL 却是一个端点对应一份模式，每一个客户端，无论是用去年那个构建版本的移动应用，还是今天早上刚部署的内部仪表盘，查询的都是同一张图。没有哪个 URL 可以被分叉出去。停用一个字段，意味着就地把它标记为已停用，而这份模式所有人早就依赖着；这让这套纪律和 REST 完全不同，尽管底层要解决的问题，也就是告诉调用方某样东西即将消失，和&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;API 停用&lt;/a&gt;一般性地讲的其实是同一个问题。&lt;/p&gt;
&lt;h2&gt;既然没有版本可以升，GraphQL 到底怎么把一个字段标记成已停用&lt;/h2&gt;
&lt;p&gt;靠直接加在字段上的 &lt;a href=&quot;https://spec.graphql.org/October2021/#sec--deprecated&quot;&gt;&lt;code&gt;@deprecated&lt;/code&gt; 指令&lt;/a&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;type Product {
  price: Float @deprecated(reason: &amp;quot;Use priceV2 for multi-currency support.&amp;quot;)
  priceV2: Money
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个字段依然可以被查询。它不会消失，不会返回 404，也不会改变行为；它只是携带了一条机器可读的说明，大多数 GraphQL 工具，GraphiQL、Apollo Studio、模式检查工具，都会把这条说明展示给任何浏览这份模式、或者对着它写查询的人看。这就是整个机制。没有单独的停用端点，没有响应头，规范也没有要求附带任何文档，这既是它的吸引力，也是它的陷阱：这个指令加起来很容易，被忽略起来也同样容易，因为没有任何东西强迫客户端去看它。&lt;/p&gt;
&lt;h2&gt;到底有没有人真的会去看停用原因&lt;/h2&gt;
&lt;p&gt;只有直接使用这份模式的人才会看到，也就是通过内省或者对模式有感知的编辑器去使用的人，而这批受众比一份 API 体验日志的常规读者要小得多。一个六个月前针对某个查询构建出来的移动应用，早就把那条查询烤进了它的二进制文件里；无论那个字段停不停用，它都会继续请求 &lt;code&gt;price&lt;/code&gt;，也会继续得到答案，直到有人用新字段重新构建这个应用并发布一次更新为止。这个指令告诉正在写新代码的开发者不要再用那个旧字段。但对那个已经发布、正在运行的客户端，它什么都做不了。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;触达到谁&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@deprecated&lt;/code&gt; 指令&lt;/td&gt;
&lt;td&gt;浏览模式或编写新查询的开发者&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;模式检查工具的 CI 失败&lt;/td&gt;
&lt;td&gt;拥有客户端代码库的团队，前提是他们真的跑了这套检查&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;一条体验日志记录&lt;/td&gt;
&lt;td&gt;任何读到它的人，包括没有检查工具的客户端团队&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;什么都没有（字段照常工作）&lt;/td&gt;
&lt;td&gt;一个已经构建好、还在用旧字段的客户端&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;一个已停用的字段是不是还应该拿到一条体验日志记录&lt;/h2&gt;
&lt;p&gt;应该，而且它做的事情比那个指令单独能做的要多，因为体验日志能触达到指令触达不到的人：一个消费这张图、却从不浏览它模式的合作方团队，一个针对几个月前缓存下来的那份模式副本构建出来的客户端，任何只有读到一段散文才会注意到这件事的人。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志&lt;/a&gt;讲的是一条记录一般性地欠调用方什么；而一条 GraphQL 记录欠的是 REST 几乎从来不需要明说的一件事，因为 REST 的调用方可以直接从版本号里推断出来：那个旧字段今天是不是还能用、是不是带着警告还能用，还是已经真的不再返回数据了。光靠那个指令，对一个从没打开过这份模式的读者来说，这些问题一个都回答不了。&lt;/p&gt;
&lt;h2&gt;到底什么时候把一个字段从模式里删掉才算真的安全&lt;/h2&gt;
&lt;p&gt;只有当查询日志显示已经没人再请求它的时候才算安全，这是一个关于使用情况的问题，而不是关于日历的问题。一个字段可以挂着 &lt;code&gt;@deprecated&lt;/code&gt; 整整一年，却依然是某个从未重新构建过的客户端赖以运作的支柱；像 REST 的 &lt;code&gt;Sunset&lt;/code&gt; 经常做的那样，按一个固定的时间表把它删掉，会在没有任何调用方能采取行动的警告下弄坏那个客户端，因为除了它从没读过的那个指令之外，GraphQL 没给它任何可以据以行动的东西。在承诺一个删除日期之前，先把字段级别的使用情况记录下来，把任何非零的查询计数当成一次暂停，而不是一个倒计时。&lt;/p&gt;
&lt;h2&gt;添加一个字段是不是和在 REST API 里承担同样的风险&lt;/h2&gt;
&lt;p&gt;对新增的字段来说风险更小，因为一个 GraphQL 客户端只会拿到它明确要求的那些字段。在 &lt;code&gt;price&lt;/code&gt; 旁边加一个 &lt;code&gt;priceV2&lt;/code&gt;，不会像在 REST 的 JSON 响应里加一个字段那样，可能弄坏一个严格的反序列化器去破坏现有查询，因为没有任何东西强迫客户端去请求那个新字段。给一个已有的枚举加一个新值则是同一口气里就该点出来的例外：强类型语言会鼓励客户端针对每一个枚举值都穷举式地做分支判断，而这样的客户端一旦遇到新值就会立刻崩掉，跟有没有查询请求过它毫无关系。这种安全性只对客户端主动选择接收的字段和联合类型成员成立；对一个由客户端代码手工穷举出来的封闭集合，它并不成立。&lt;/p&gt;
&lt;h2&gt;一条 GraphQL 体验日志记录需要什么，是 REST 记录不需要的&lt;/h2&gt;
&lt;p&gt;需要的是查询的形态，而不只是字段名，因为「&lt;code&gt;price&lt;/code&gt; 字段已停用」恰恰缺了调用方真正需要的那一块：到底哪些类型、哪些查询碰到了它。一条有用的记录会点名类型、字段、替代字段，如果能生成的话，还会点名生产环境里那些仍在请求旧形态的真实查询。这最后一块，把停用通知和真实使用情况绑在一起，正是 REST 调用方能从一个 URL 的服务器日志里免费拿到、而 GraphQL 调用方拿不到的东西，因为不管请求的是什么，每一条查询打的都是同一个端点。&lt;/p&gt;
&lt;h2&gt;除了字段之外，还有什么能带上 &lt;code&gt;@deprecated&lt;/code&gt; 指令&lt;/h2&gt;
&lt;p&gt;枚举值，用的是同一个指令，只不过加在值本身的定义上，而不是加在字段上：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-graphql&quot;&gt;enum ShippingMethod {
  STANDARD
  EXPRESS
  OVERNIGHT @deprecated(reason: &amp;quot;Use EXPRESS with priority: true instead.&amp;quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;规范把 &lt;code&gt;@deprecated&lt;/code&gt; 明确定义在恰好两个位置上，一个字段定义或者一个枚举值，在稳定发布版本里没
有第三个位置；参数和输入字段级别的停用只存在于更晚的草案文本里，并不是大多数服务端今天实际实
现的东西。用这种方式标记过的枚举值依然是服务端可以继续返回或接受的一个合法值，这和一个已停用
字段给出的那个不破坏性的承诺是一样的，也正因如此，在真正删除这个值之前先这样发布出去才是安
全的。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;GraphQL 支持类似整个端点的 Sunset 响应头那样的东西吗?&lt;/strong&gt;
不支持，因为通常只有一个端点。停用的时间安排活在字段这个层级，存在于 &lt;code&gt;@deprecated&lt;/code&gt; 指令的原因文本里，以及团队随之发布的任何体验日志或迁移指南里，而不在一个客户端能以编程方式读取的响应头里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个已停用的字段能不能先删掉，之后再用不同的类型加回来?&lt;/strong&gt;
只能作为一个新的字段名加回来。用变了的类型重新引入同一个字段名，正是停用周期这套机制存在的意义所在，就是要避免这种破坏性变更；像 &lt;code&gt;priceV2&lt;/code&gt; 那样，给替代字段起个自己的名字，让旧的那个彻底消亡之后，那个名字才空出来可以被重新使用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@deprecated&lt;/code&gt; 的原因文本应该链接到那条体验日志记录吗?&lt;/strong&gt;
应该，只要模式工具支持这么做。原因字段接受一个普通字符串，而字符串里的一个 URL，就是从一个盯着内省输出发呆的开发者，通向一条体验日志记录能给出的那个更完整解释之间，最短的一条路径。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GraphQL 的模式变更有没有可能以某种 REST 做不到的方式保持向后兼容?&lt;/strong&gt;
增量式的字段变更可以，原因就在上面：客户端只会拿到它们请求的东西。新增的枚举值是例外，因为一个穷举一个封闭集合的客户端，会在遇到一个它没预料到的值时崩掉。删除和类型变更和它们在 REST 里的对应变更一样，破坏性完全相同。&lt;/p&gt;
</content:encoded></item><item><title>该怎样写一份真正管用的 API 迁移指南</title><link>https://changeloop.dev/blog/zh/api-migration-guide/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/api-migration-guide/</guid><description>API 迁移指南把一次不兼容的变更变成一份清单，而不是一次故障。本文说明它该写什么、何时发布、谁来写，以及为什么光靠一条体验日志记录不够。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;API 迁移指南是那种能把一次不兼容的变更，变成一份清单、而不是一场故障的文档：到底发生了什么变化，该怎么应对，以及截止到什么时候。一条体验日志记录可以用两句话说清楚一次不兼容的变更；而迁移指南才是调用方在那两句话说出&amp;quot;这会破坏你的调用&amp;quot;、而她需要确切知道该改哪里的时候，真正会打开的东西。只发布记录而不配上指南，正是调用方最终从一张支持工单里、而不是从那份本该阻止这一切发生的文档里，才得知这次不兼容变更的原因。&lt;/p&gt;
&lt;h2&gt;API 迁移指南到底是什么&lt;/h2&gt;
&lt;p&gt;一份一步一步的文档，把调用方从 API 的旧形态带到新形态，写给那些手头已经有代码需要修改的人看，而不是写给那些还在犹豫要不要采用这个 API 的人看。这个区别很重要：迁移指南默认已经存在一个正在运行的集成、已经有真实的生产流量在跑，所以它必须覆盖回滚、部分迁移，以及怎样判断迁移到底成不成功，而这些都是第一次接入的指南完全不需要处理的问题。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文档&lt;/th&gt;
&lt;th&gt;默认的前提&lt;/th&gt;
&lt;th&gt;回答的问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;迁移指南&lt;/td&gt;
&lt;td&gt;一个已经存在的集成&lt;/td&gt;
&lt;td&gt;我该怎样从旧形态迁移到新形态?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;体验日志记录&lt;/td&gt;
&lt;td&gt;什么都不需要，只需要读者会去查看&lt;/td&gt;
&lt;td&gt;发生了什么变化，又是什么时候?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API 参考文档&lt;/td&gt;
&lt;td&gt;什么都不需要，或者是第一次接入&lt;/td&gt;
&lt;td&gt;这个接口到底是做什么用的?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;停用通知&lt;/td&gt;
&lt;td&gt;一个还在用旧版本的集成&lt;/td&gt;
&lt;td&gt;这个东西什么时候会停止工作?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;迁移指南通常就夹在最后两者之间：停用通知启动了一个倒计时，而迁移指南则是调用方在这个倒计时结束之前必须遵循的东西。&lt;/p&gt;
&lt;h2&gt;一次变更什么时候需要迁移指南，而不只是一条体验日志记录&lt;/h2&gt;
&lt;p&gt;当旧行为和新行为之间不止一步之遥，或者这次变更触及了足够多的调用点，让调用方从一个具体的示例中获得的帮助，远大于只读一段文字描述的时候。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是不兼容的变更，又该如何发布它&lt;/a&gt;完整讲解了判断一次变更是否不兼容的标准；一旦答案是肯定的，第二个问题就变成了，这次修复到底是一行代码的调整，还是一次真正的迁移。一个改了名字的字段，调用方光靠一条体验日志记录就能应付过去。而认证、分页或者错误处理上的变更，几乎总是配得上一份专门的指南，因为正确的替代代码，从一句话的描述里根本看不出来。&lt;/p&gt;
&lt;h2&gt;一份迁移指南必须包含什么内容&lt;/h2&gt;
&lt;p&gt;五样东西，而只要漏掉其中任何一样，指南就会变成一个调用方只读一次、然后就转向反复试错的页面。旧代码，按它在真实项目里实际出现的样子展示出来。新代码，用同样的方式展示，而不是用一段抽象的话去描述两者的差异。如果什么都不改，到底会坏在哪里，要直白地说出来，因为&amp;quot;什么都不会坏&amp;quot;本身就是一个常见且合理的答案，但调用方仍然需要明确地听到这句话。一种能验证迁移是否真的成功的方法，比如某个响应字段，或者一个可以检查的状态码。还有一份时间表：旧行为到底什么时候会停止工作，以及在这期间新旧两种形态是否都可用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## 将货币字段从 float 迁移到 integer (v3.0.0)

之前：
  { &amp;quot;amount&amp;quot;: 19.99 }

之后：
  { &amp;quot;amount&amp;quot;: 1999 }  // 最小货币单位（分）

发生了什么变化：`amount` 现在是账户货币最小单位下的一个整数。
从 2026 年 10 月 1 日起，把 `amount` 当作 float 来读取的代码，会
读到一个大了 100 倍的数值。

验证方法：迁移之后，一笔 19.99 美元的扣款应该读作
`amount: 1999`，而不是 `amount: 19.99`。

时间表：v2 会继续返回 float，直到 2027 年 1 月 15 日为止。v3 从
上线那一刻起就返回整数。目前两个版本都在正常运行。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这五样东西中的每一样，回答的都是一个调用方本来只能靠猜测、或者只能去问支持团队才能知道答案的问题，而这，正是一份迁移指南真正省下来的成本。&lt;/p&gt;
&lt;h2&gt;谁应该来写它，又该在什么时候写&lt;/h2&gt;
&lt;p&gt;设计这次变更的人，就在它被发布的那一刻动手写，而不是靠支持团队一周之后再从工单里把它拼凑出来。做出这个决定的人，才知道旧行为里到底哪些部分本来就不该有人依赖、哪些又只是一种意外形成的约定；而由一个不了解这些背景的人事后补写的指南，往往要么把显而易见的地方解释得过头，要么恰恰漏掉了那个真正会让人栽跟头的边界情况。指南和宣布这次不兼容变更的体验日志记录，理应同时发布，而且记录本身应该链接到指南，而不是把指南的内容重新说一遍。&lt;/p&gt;
&lt;h2&gt;这和版本管理、以及 API 体验日志之间是什么关系&lt;/h2&gt;
&lt;p&gt;关系非常直接：迁移指南就是 &lt;a href=&quot;https://changeloop.dev/blog/zh/semantic-versioning-changelog/&quot;&gt;语义化版本控制，应该怎样配合你的体验日志&lt;/a&gt; 里，一条 MAJOR 记录只用一句话概括的那件事的详细版本。体验日志记录说明了这次变更是不兼容的，也大致说明了发生了什么变化；而迁移指南，正是那条记录本该携带的那个链接。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志：该公开什么内容，又是谁在读它&lt;/a&gt; 把迁移指南列为一个 API 维护的五份文档之一，每一份都回答着不同的问题；这一份回答的正是&amp;quot;我到底该怎样真正从 A 迁移到 B&amp;quot;，而它之所以配得上拥有自己的一整个页面，恰恰是因为这个答案对一条体验日志记录来说，通常实在是太长了。&lt;/p&gt;
&lt;h2&gt;一份迁移指南应该保持发布状态多久&lt;/h2&gt;
&lt;p&gt;至少要持续到旧行为彻底不可访问为止，理想情况下，之后也应该继续保留。一个忽略了三次停用通知、迟到了十八个月才来迁移的调用方，依然需要这份指南，而在旧行为下线的当天就把它删掉，只会保证那个最需要它的调用方，恰恰找不到它。把它放在一个稳定的网址上，然后更新时间表那部分内容，而不是把整个页面撤下来。Stripe 自己的
&lt;a href=&quot;https://docs.stripe.com/upgrades&quot;&gt;升级指南&lt;/a&gt;就是这种做法的一个公开范例：只有一个页面，随着
每一次新版本发布持续更新，而不是每个版本各写一份、下一版一上线就立刻过时的独立文档。你自己
的指南也应该放在同样容易被找到的地方，紧挨着调用方本来就在读的&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;文档&lt;/a&gt;，而不是被埋进
一个体验日志的归档里。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;是不是每一次不兼容的变更都需要一份迁移指南?&lt;/strong&gt;
不是。如果一次变更调用方光靠一条体验日志记录就能自行解决，比如一个改了名字、但替代方案很明显的字段，那就不需要单独的指南。但如果一次变更触及了多个调用点，或者需要一个具体示例才能讲清楚，那就需要。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;迁移指南到底应该放在 API 文档旁边，还是应该放进体验日志里?&lt;/strong&gt;
放在文档旁边，然后从体验日志记录里链接过去。记录是订阅者最先看到的东西；指南则是她一旦决定要采取行动时才需要用到的东西，理应放在调用方本来就已经在用的那份参考资料旁边。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;迁移指南和停用通知之间到底有什么区别?&lt;/strong&gt;
停用通知宣布的是某样东西即将消失，以及截止到什么时候。迁移指南则是关于该怎么应对这件事的具体说明。一份没有链接到迁移指南的停用通知，只是给了调用方一个截止日期，却没有告诉她该怎么在这个日期之前完成迁移。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在迁移窗口期内，旧行为和新行为是不是都应该被记录下来?&lt;/strong&gt;
是的，如果可能的话最好放在同一个页面上，这样调用方就能确切看到到底发生了什么变化，而不必自己从两份分别在不同时间写成的文档里，把这些信息拼凑起来。&lt;/p&gt;
</content:encoded></item><item><title>一个跑在 GitHub Actions 里的体验日志检查</title><link>https://changeloop.dev/blog/zh/changelog-ci-enforcement/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/changelog-ci-enforcement/</guid><description>这个跑在 GitHub Actions 里的体验日志检查，会在缺少记录时拒绝合并，因为依赖人记得的步骤迟早会失效。本文讲它能验证什么、不能验证什么，以及紧急热修复怎么办。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;每一个手动维护体验日志的团队，都在同一种事故之后经历过同一种对话：一个发布版本没有记录就上线了，有人问为什么，诚实的答案是那个本该写记录的人当时在赶进度，而体验日志这个步骤只存在于记忆里。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;讲的是流水线能安全自动化哪些部分、哪些还是离不开人；而在 CI 里做一次体验日志检查，是这个问题的另外一半，因为把写作这件事自动化，并不能解决根本没人有义务先去触发它的问题。大多数团队本来就已经在 GitHub Actions 里跑他们 pull request 的各项检查，所以这个检查也顺理成章地放在这里。&lt;/p&gt;
&lt;h2&gt;为什么&amp;quot;我们要求大家自己添加记录&amp;quot;会按照一种可以预测的模式失败&lt;/h2&gt;
&lt;p&gt;因为它要在一个 pull request 里和其他所有事情争夺注意力，而它又是唯一一个跳过了也没有立即后果的部分。测试失败会响亮地拦住合并。缺一条体验日志记录什么都拦不住，所以只要有人在赶时间，它就会输掉，而现实里大部分时候人都在赶时间。一条靠记性维持的规则，会恰好以你能预料到的速度衰败：大家刚达成一致的头几周还不错，一旦真正在意这件事的人休假或者换了团队，就会悄悄没人管了。&lt;/p&gt;
&lt;h2&gt;一个针对体验日志记录的 CI 检查，到底在验证什么&lt;/h2&gt;
&lt;p&gt;不是文字的质量，只是这条记录存不存在、格式对不对，而这恰好是一个跑在 CI 里、而不是跑在人脑子里的体验日志检查该管的范围。一种常见的形态是：检查会看这个 PR 的 diff，要求要么在 changeset 目录下有一个新文件（&lt;a href=&quot;https://github.com/changesets/changesets&quot;&gt;Changesets&lt;/a&gt; 和类似工具用的就是这个模式），要么体验日志文件里有一行改动，两者都没有就让 build 失败。至于这条记录到底写得怎么样，这个审查始终发生在它一直发生的地方，也就是 code review 里，因为那种判断本来就不该交给一个脚本。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CI 检查验证的东西&lt;/th&gt;
&lt;th&gt;它不验证的东西&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;diff 里存在一个 changeset 或体验日志的改动行&lt;/td&gt;
&lt;td&gt;措辞是否清楚&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;在 monorepo 里，这条记录引用的包对不对&lt;/td&gt;
&lt;td&gt;这次改动到底值不值得写一条记录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件语法上是否合法（frontmatter、JSON 形态）&lt;/td&gt;
&lt;td&gt;这条记录对影响范围是否说了实话&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;是不是每个 PR 都需要这个，还是有些改动可以豁免&lt;/h2&gt;
&lt;p&gt;有一些是可以豁免的，而豁免清单恰恰是这类系统真正被搭建起来还是被放弃的分水岭。一次看不出可见影响的依赖升级、一次只改测试的改动、一次不改变行为的内部重构：这些都不该逼着贡献者为一件读体验日志的人根本不关心的事情硬编一条记录出来。有效的模式是让贡献者可以打上（&lt;code&gt;no-changelog-needed&lt;/code&gt;）这样一个标签或标志，不需要文件就能满足 CI 检查，由批准这个 PR 的人来审查，这样豁免本身也要经过一条记录本该经过的同一种审视。&lt;/p&gt;
&lt;h2&gt;像紧急热修复这样正当的例外该怎么处理&lt;/h2&gt;
&lt;p&gt;Gate 应该卡在合并这一步，而不是部署那一步：一个真正处在时间压力下的热修复，只要 CI 检查能被&amp;quot;意图&amp;quot;满足而不是非要一段写完的文字，就可以带着一条占位记录或者一个后续 ticket 合并；有些团队会接受一条一行的 stub，让维护者在下一次发布切分之前再打磨。Gate 绝对不该允许的，是悄悄跳过这一步，因为一条被遗忘的 stub 总归比一条从来不存在的记录小一号的失败，而 stub 至少留下了一条以后有人能找到的痕迹。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/changelog-check.yml
on:
  pull_request:
    types: [opened, synchronize, reopened, labeled, unlabeled]
jobs:
  changelog:
    if: &amp;gt;-
      !contains(github.event.pull_request.labels.*.name,
      &amp;#39;no-changelog-needed&amp;#39;)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # the diff needs the base branch
      - name: Require changelog entry
        run: |
          base=&amp;quot;origin/${{ github.base_ref }}&amp;quot;
          if ! git diff --name-only &amp;quot;$base&amp;quot;...HEAD \
              | grep -q &amp;#39;^\.changeset/&amp;#39;; then
            echo &amp;quot;No changeset. Add one, or have a maintainer&amp;quot;
            echo &amp;quot;apply the no-changelog-needed label.&amp;quot;
            exit 1
          fi
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;在这个检查真正开始拦住别人的 PR 之前，怎么知道它本身是对的&lt;/h2&gt;
&lt;p&gt;先对着一个用完就扔的分支开一个练手 PR：一个带 changeset，一个不带，一个带着豁免标签，在这个检查
真的要去管别人的工作之前，先确认这三种情况各自都得到了你预期的结果。一个因为某个条件写反了、结果每个 PR 都放行的失败开放式体验日志检查，比完全没有这个检查还要糟
糕，因为它看起来像是有覆盖，实际上什么都没挡住。对着同一个文件手动跑一次 &lt;code&gt;workflow_dispatch&lt;/code&gt;，拿最近几个已经合并的
PR 试一遍，不需要真的开一个 pull request，就能抓住大部分这类错误。&lt;/p&gt;
&lt;h2&gt;同样的思路，在 GitHub Actions 之外也一样成立吗&lt;/h2&gt;
&lt;p&gt;形状是一样的，变的只是语法。GitLab CI 用一个检查 &lt;code&gt;$CI_MERGE_REQUEST_LABELS&lt;/code&gt; 的 job &lt;code&gt;rules&lt;/code&gt; 块，
来表达和 GitHub Actions 里的 &lt;code&gt;if&lt;/code&gt; 一样的规则，一个强制要求的合并请求批准，也可以替代豁免审查这
一步。这篇文章讲的是 GitHub Actions，因为这是大多数读者本来就已经在用的平台，但底层的要求，也
就是一个由机器检查的 gate，而不是一条只是口头约定的规范，在任何一个在合并之前跑 CI 的地方都是
一样的。&lt;/p&gt;
&lt;h2&gt;这在 monorepo 里也是一样的工作方式吗&lt;/h2&gt;
&lt;p&gt;还需要多一样东西：这条记录到底是给哪个包写的。&lt;a href=&quot;https://changeloop.dev/blog/zh/monorepo-changelogs/&quot;&gt;Monorepo 的体验日志&lt;/a&gt;讲的是，一旦各个包开始独立发布，整个仓库共用一份文件的方式为什么就不管用了；CI 检查继承的是同一个要求；一个没有指名具体包的 changeset，并不能有效证明正确的那份体验日志真的会更新，只能证明 diff 里某个地方有个文件动过。为这件事专门打造的工具（Changesets 是 JavaScript 生态里常见的那一个）会要求贡献者在创建 changeset 的那一刻，就选好受影响的包和一个 semver 的升级幅度，这样 CI 检查就能免费拿到这两个信息，而不用事后再去推断。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;CI 检查应该拦住合并，还是只发出警告就够了?&lt;/strong&gt;
应该拦住。警告在功能上和客客气气地请求没什么两样，而那种方式已经失败过了。豁免标签存在的意义，恰恰就是让一个真正只需要警告的情况，也能有一条走过同一道严格 gate 的正当路径。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;谁来审查一个豁免标签是不是被正确使用了?&lt;/strong&gt;
批准这个 pull request 的人，作为他反正已经在做的那次审查的一部分。这个标签绝不应该由贡献者自己打上就算数、不经审查，否则它就变成了 gate 原本要堵住的那个悄悄的漏洞。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在 CI 里强制要求这个，能取代体验日志自动化流水线吗?&lt;/strong&gt;
不能，它是在给那条流水线喂料。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;讲的是怎么把结构化的记录变成一个页面、一条信息流、一封邮件；而 CI 检查保证的，正是这些结构化的记录本身首先得存在，才谈得上自动化。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;值得最先搭建的、这件事最小的版本是什么?&lt;/strong&gt;
一个单独的检查，只要在指定的体验日志目录下没有任何文件改动就失败，配上一个豁免标签。按包路由和给 monorepo 做 semver 推断，都可以留到以后再做；最核心的那个习惯，一条记录存在，或者有人明确说了不需要，才是从第一天起就值得拥有的东西。&lt;/p&gt;
</content:encoded></item><item><title>该怎样拒绝一个功能请求，又不失去这位客户</title><link>https://changeloop.dev/blog/zh/declining-feature-requests/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/declining-feature-requests/</guid><description>闭环通常意味着告诉某人她要的东西已经发布，真正难的是说不，还要不伤害和客户的关系。本文说明什么让拒绝特别糟糕，好的拒绝怎样开口，并给出可直接使用的话术。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;闭环通常意味着告诉某个人，她提出的请求已经发布了。而真正难的那一半，是大多数追踪系统根本没有任何流程去处理的那一半：要说不。大多数功能请求，其实从来都不会被真正发布出来，这意味着一个产品真正欠它用户的闭环，大部分其实是拒绝，而不是公告，而一次处理得很糟糕的拒绝，付出的善意代价，远比保持沉默要高得多。而如果处理得好，代价几乎可以低到接近于零，因为大多数时候，提出请求的人真正最想要的，其实是知道自己被听见了，而不是那个功能本身。&lt;/p&gt;
&lt;h2&gt;为什么把拒绝做好，和把发布做好同样重要&lt;/h2&gt;
&lt;p&gt;因为沉默读起来就像是一次没有任何解释的拒绝，而一个附带了解释的&amp;quot;不&amp;quot;，读起来则像是一种被认真对待的关注。一个什么都没听到的人，只能猜测自己的请求要么被忽略了，要么就是彻底石沉大海了，而这两种猜测，都会教会她以后干脆懒得再开口去问，这最终导致的结果，其实和产品直接给出一次真正的拒绝完全一样，只不过达到这个结果的过程更慢，路上还多攒了不少怨气。一句清清楚楚说不、并且给出理由的回复，能把闭环闭合得和一个已经发布的功能一样彻底，而且做到这一点的速度还更快。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;回应方式&lt;/th&gt;
&lt;th&gt;提出请求的人会学到什么&lt;/th&gt;
&lt;th&gt;对这段关系造成的代价&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;保持沉默&lt;/td&gt;
&lt;td&gt;没人读过，或者根本没人在乎&lt;/td&gt;
&lt;td&gt;很高，而且每来一次新请求，代价还会继续累积&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;没有理由的自动回复&lt;/td&gt;
&lt;td&gt;反正被扔进了某个队列里，遥遥无期&lt;/td&gt;
&lt;td&gt;中等；能换来一点时间，却换不来任何信任&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;附带理由的拒绝&lt;/td&gt;
&lt;td&gt;确实被读过、被认真考虑过，也得到了回应&lt;/td&gt;
&lt;td&gt;较低，前提是这个理由是真诚的&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;附带替代方案的拒绝&lt;/td&gt;
&lt;td&gt;真正的需求确实被认真听见了&lt;/td&gt;
&lt;td&gt;最低；往往还能反过来建立起信任&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;到底是什么让一次拒绝显得特别糟糕&lt;/h2&gt;
&lt;p&gt;几乎总是三件事叠加在一起造成的。千篇一律：一句套话式的&amp;quot;感谢你的反馈&amp;quot;，如果压根没提到对方到底具体要的是什么，读起来就会像是根本没被认真读过一样，哪怕实际上确实读过。拖延：如果一次拒绝是在提出请求六个月之后才姗姗来迟，那时候对方可能都已经忘了自己问过这件事，这种感觉会比一句干脆利落的&amp;quot;不&amp;quot;还要糟糕，因为它暗示着这个请求根本没被真正考虑过就被拒绝了，而是原封不动地被晾在了那里。还有一种撑不住的理由：&amp;quot;这不在我们的路线图上&amp;quot;，这句话其实什么都没回答，而&amp;quot;这需要重新设计权限系统的运作方式，而我们今年并不打算去动这一块&amp;quot;，则给了提出请求的人一个她真正可以拿去评估的东西，而且如果这件事对她来说确实足够重要，她还能拿这个理由去升级诉求，或者另想办法绕过去。&lt;/p&gt;
&lt;h2&gt;一次好的拒绝，到底应该真正说些什么&lt;/h2&gt;
&lt;p&gt;按这个顺序，四件事。首先是一句确认，明确点出对方具体提出的是哪个请求，而不是一句泛泛而谈的转述；接着是真正的原因，即便这个诚实的原因是&amp;quot;这和产品要走的方向不太一致&amp;quot;这种听起来不那么好听的话，也要老老实实地说出来，而不是找一个更好听的借口来搪塞；然后要说清楚，这扇门到底是彻底关上了，还是只是现在暂时没开，因为这两种情况需要完全不同的语气；最后，如果确实存在的话，还要给出一个能解决背后那个真正需求的替代方案，即便它并不是字面意义上被要求的那个功能。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;你好，Jamie，

谢谢你提出为团队邀请添加批量 CSV 导入功能的请求。我们认真评估过
了，但我们不会去做这个功能：出于安全方面的考虑，我们的邀请流程
就是围绕着对每一位新成员逐一进行单独审核而设计的，而批量导入
恰恰会和这个设计初衷背道而驰，而且这是刻意为之，不是我们的疏忽。

如果你真正想解决的问题，其实是想快速邀请一个规模较大的团队，
我们的 API 支持通过脚本批量发起单独的邀请，这样几乎能获得同样
的速度，同时又不会绕过审核环节：[链接]。如果你需要帮忙把这个
设置起来，随时告诉我们。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请注意这段话做到了模板做不到的几件事：它点名了那个具体的功能，给出的理由和一个真实的设计决策绑在一起、而不是一句含糊其辞的政策说辞，还给出了一条能真正解决背后那个问题的路径，而不只是单纯把工单关掉了事。&lt;/p&gt;
&lt;h2&gt;这和在一个已发布功能上闭环有什么不同&lt;/h2&gt;
&lt;p&gt;背后的机制是相似的，但语气完全不同。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭环：从客户反馈到告知客户&lt;/a&gt; 完整讲解了功能已发布的那种情况，在那种情况下，这条消息本身就是个好消息，最大的风险只是忘了把它发出去而已。而拒绝则是个坏消息，或者至少也是一个不受欢迎的消息，它需要在给出的理由上多花一些心思，在传递方式上则要少一些自动化：一条&amp;quot;功能已发布&amp;quot;的通知，完全可以是由状态变化自动触发的一条模板化评论，但一条读起来明显像是套模板生成的拒绝，恰恰就是这整套做法本来想要极力避免的那种失败方式。不过两者依然共享一个前提条件：最初的那个请求，必须始终和提出请求的人保持关联，这正是 &lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;功能请求追踪&lt;/a&gt; 里讲到的那套追踪纪律，否则这两种消息里，无论哪一种，都根本没办法单独发送给对应的那个人。&lt;/p&gt;
&lt;h2&gt;一次拒绝是不是应该公开，就像公开路线图上的一个状态那样&lt;/h2&gt;
&lt;p&gt;通常不该公开的是那个具体的理由，尽管状态本身倒是可以公开。&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;公开路线图&lt;/a&gt; 完整讲解了那些提出请求的人不用再重新问一遍、就能自己查到的状态标签，而一个&amp;quot;已拒绝&amp;quot;或者&amp;quot;暂不计划&amp;quot;的状态，完全可以是这套系统的一部分。但那个详细的理由，尤其是当它涉及到内部优先级排序或者一些不太光彩的背景信息时，通常更适合放在一对一的单独回复里，而不是放在一个公开的状态页面上，因为在那种公开页面上，同样一段措辞必须要能同时说服每一位读者，而不只是那个真正提出了这个问题的人。&lt;/p&gt;
&lt;h2&gt;是不是每一个被拒绝的请求都值得一条一对一的回复&lt;/h2&gt;
&lt;p&gt;每一个来自一个具名、且能够联系到的人的请求，都值得，哪怕只是简短的一句话。高流量、重复出现，或者匿名的请求，才是例外情况：把相似的请求归到一组、按组统一回复一次，或者更新一个共享的状态标签，在一对一回复真的没法规模化的时候，是完全合理的做法。需要守住的那条底线是，&amp;quot;我们没办法一对一回复每一个人&amp;quot;这句话，应该是一个经过实际请求量核实过的、真实存在的运营限制，而不能变成一个默认的借口，用来跳过一条本来两分钟就能回完的回复。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;是应该用一个站不住脚的理由迅速拒绝，还是应该多花点时间想一个更好的理由?&lt;/strong&gt;
用一个真诚的理由迅速回应，胜过把这两者分开来单独做。哪怕理由很简短，一条附带真实理由的快速回复，也会胜过一条理由措辞很完美、但姗姗来迟的回复；拖延本身，就是损害信任的一部分原因。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一次拒绝，是不是应该承诺以后会重新考虑这个请求?&lt;/strong&gt;
只有在这件事真的有可能发生、而且确实存在某种机制能真正重新考虑它的时候才应该这样说，比如说，有一个标签能让这个请求在下一次规划周期里重新被提上台面。如果没有这种实际机制，一句含糊的&amp;quot;我们会记在心里&amp;quot;，本质上和沉默没什么两样，只是措辞听起来更客气一点而已。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果这个真实的理由，恰好是公司没法公开分享的内容，比如出于竞争方面的考虑，该怎么办?&lt;/strong&gt;
那就直接说清楚，而不是去编造一个听起来更委婉的理由。&amp;quot;这里的具体原因我们没法透露，但这确实不是我们计划要做的东西&amp;quot;，这句话比一个一被追问就站不住脚的编造解释，要诚实得多，也更能赢得对方的尊重。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;拒绝了一个请求，是不是就意味着应该把它从追踪系统里删掉?&lt;/strong&gt;
不是。把它保留下来，标记为已拒绝并附上理由，这样它就能成为下一次类似请求被归类参照的那个模式的一部分，等以后背景情况发生变化了（比如出现了新的集成需求，或者团队的优先级变了），也能让这个请求重新被提上台面，而不用从零开始重新评估一遍。&lt;/p&gt;
</content:encoded></item><item><title>Feature flag 发布说明：该说什么，又该什么时候说</title><link>https://changeloop.dev/blog/zh/feature-flags-feature-requests/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/feature-flags-feature-requests/</guid><description>Feature flag 发布说明必须分清代码合并和真正发布，因为有 flag 时两者不再同时发生。时机不对就关闭反馈闭环，是在通知别人一个看不见的功能。本文讲如何判断时机。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;给一条功能请求关闭反馈闭环，默认的前提是存在一个干净利落的时刻，那个东西就是在那一刻被真正发布出去的。Feature flag 恰恰抹掉了这个时刻，这也正是 feature flag 发布说明难以把握时机的原因。代码合并了，flag 也已经存在了，但接下来的好几天甚至好几周里，这个功能会同时处于两种状态：它已经在生产环境里活生生地跑着，同时又对几乎所有可能想用它的人都不可见，而这些人里，往往就包括最初提出这个请求的那个人。通知得太早，会让对方一头撞上一个根本还不存在的功能。通知得太晚，则会让本来应该建立信任的这个反馈闭环，读起来反倒像是被遗忘了。&lt;/p&gt;
&lt;h2&gt;为什么 flag 会打破&amp;quot;发布了，就通知&amp;quot;这套惯常的顺序&lt;/h2&gt;
&lt;p&gt;因为它把一个事件，硬生生拆成了至少两个：代码变成上线状态，和 flag 为某个特定账户被打开，这是两件事。任何一套关闭反馈闭环的流程，默认都假设这两件事是同时发生的，这个假设对大多数常规发布来说是成立的，但对任何藏在用于灰度发布、定向投放、或者作为紧急熔断开关的 flag 背后的东西来说，这个假设是不成立的。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;关闭客户反馈闭环&lt;/a&gt; 里描述的做法，是恰好在一条体验日志记录被批准并发布的那一刻，去通知提出请求的那个人；那一步的设计前提，是发布这条记录和这个功能变得可用，正好是同一个时刻，而 flag 恰恰就是这两者不再是同一个时刻的那种情况。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;时刻&lt;/th&gt;
&lt;th&gt;事实是什么&lt;/th&gt;
&lt;th&gt;是不是已经该通知提出请求的人了&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;代码已合并，flag 在所有地方都是关闭的&lt;/td&gt;
&lt;td&gt;功能已经存在，但没人能用&lt;/td&gt;
&lt;td&gt;不该&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flag 为提出请求那个人的账户打开了&lt;/td&gt;
&lt;td&gt;功能已经存在，这个具体的人可以用它了&lt;/td&gt;
&lt;td&gt;该&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flag 为某个把这个人排除在外的灰度百分比打开了&lt;/td&gt;
&lt;td&gt;功能已经存在，这个人依然用不了&lt;/td&gt;
&lt;td&gt;不该&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flag 已经被彻底移除，功能就这么直接开着了&lt;/td&gt;
&lt;td&gt;功能对所有人都存在了&lt;/td&gt;
&lt;td&gt;该，如果还没通知过的话&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;到底该在什么时候通知某个人，这条真正的规则是什么&lt;/h2&gt;
&lt;p&gt;在 flag 为对方的账户打开的那一刻通知，而不是在代码合并的时候，也不是在 flag 被创建的时候。这一条规则，就足以覆盖上面表格里的每一行，因为它把通知这件事，绑定在了唯一一个对提出请求的人来说真正重要的事实上：他现在，此刻，能不能真的去用那个东西。一条绑定在代码合并或者 flag 创建那一刻的通知，实际上是一份工程进度报告，而一个提出了功能请求的人，要的从来不是进度报告，他要的是知道自己什么时候该去看一眼。&lt;/p&gt;
&lt;h2&gt;这是不是意味着提出请求的人需要提前获得或者特殊获得访问权限&lt;/h2&gt;
&lt;p&gt;不一定，而且硬性要求这么做，反而会制造出它自己的新问题。如果 flag 是出于负载或者稳定性的考虑而逐步灰度放量的，仅仅为了更快关闭一条反馈闭环，就把某一个账户塞到队伍最前面，这恰恰破坏了这次灰度发布之所以要分阶段进行的初衷。诚实的选项只有两个：要么等提出请求的那个账户自然而然地被灰度覆盖到，到那时候再通知，要么，如果紧迫程度真的能站得住脚，就有意识地提前为这个人打开 flag，把这当成灰度发布的负责人做出的一个真实决定，而不是想要发一条通知这个念头带来的副作用。&lt;/p&gt;
&lt;h2&gt;如果这个 flag 是一个紧急熔断开关，而不是一套灰度发布机制呢&lt;/h2&gt;
&lt;p&gt;那么安全的默认假设就要反过来了。一个设计初衷是为了能快速关掉某个功能，而不是为了把发布过程分阶段推进的 flag，通常意味着这个功能本来就该在创建的那一刻起就完全上线，flag 存在是出于安全考虑，而不是出于顺序上的考虑。在这种情况下，在部署那一刻就通知提出请求的人是正确的做法，跟任何一次没有 flag 的常规发布完全一样；flag 的存在只是一个运营层面的细节，不应该改变反馈闭环到底该在什么时候关闭。真正重要的区别在于这个 flag 到底是为了什么而存在的，而不是它到底存不存在。&lt;/p&gt;
&lt;h2&gt;Flag 会改变 feature flag 发布说明本身应该写的内容吗&lt;/h2&gt;
&lt;p&gt;它改变的是这条记录发布的时机，而不是它包含的内容。一条恰好在 flag 为 100% 的账户打开那一刻发布的记录，读起来会跟一条普通的体验日志记录一模一样，而这本来就是对的；一个日后才找到这条记录的读者，完全没有理由需要知道这里面曾经涉及过一个 flag。它不应该做的，是在 flag 只为一小部分灰度百分比打开的时候就被发布出去，因为一条公开的体验日志记录，会让每一个读到它的人，包括那些没有这个 flag 的账户，都跑去寻找一个他们根本找不到的功能，这其实是同一个问题的一个更糟糕的版本，只是规模从一个提出请求的人，放大到了整个产品。这条时机上的规则，正是 feature flag 发布说明和一条普通条目之间唯一的区别：内容完全一样，变的只是发布的那个日期。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;怎样写发布说明&lt;/a&gt; 讲解了同样适用于这里的&amp;quot;不需要任何操作&amp;quot;这条纪律：读者需要知道的是这件事到底跟自己有没有关系，而不只是知道它在某个地方存在这个事实本身。&lt;/p&gt;
&lt;h2&gt;产品更新邮件是不是应该对一个带 flag 的功能区别对待&lt;/h2&gt;
&lt;p&gt;应该，而且主要是通过推迟发送，而不是重写内容来实现。&lt;a href=&quot;https://changeloop.dev/blog/zh/product-update-email/&quot;&gt;产品更新邮件模板&lt;/a&gt; 讲解了定向通知跟大范围摘要邮件之间的区别；一个带 flag 的功能，恰恰是这样一种情况：一条定向通知发送之前，必须先跟接收者自己的 flag 状态核对一下时机，而这件事，一份大范围的摘要邮件根本没法轻易做到。这也是为什么，对任何还处于灰度发布中途的东西来说，摘要邮件都是一个错误的渠道的又一个理由。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Flag 已经存在、但还没为某个人打开的时候，是不是应该告诉提出请求的人他的功能&amp;quot;即将上线&amp;quot;?&lt;/strong&gt;
只有在真的有一个明确、临近的日期时才应该这么说，而且就算这样也要谨慎使用。一句没有日期的&amp;quot;即将上线&amp;quot;，在过了足够长的时间之后，读起来跟彻底沉默没什么两样，而且还会额外制造出第二个同样需要被追踪、被兑现的承诺。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;到底该由谁来判断一个 flag 的进度是不是已经足够，可以关闭反馈闭环了?&lt;/strong&gt;
应该由灰度发布的负责人来判断，而不是由通知的负责人来判断。掌握灰度发布进度的人，才知道&amp;quot;100% 的账户&amp;quot;到底是近在眼前，还是还要再等好几周；把关闭反馈闭环这一步，绑定在灰度发布的实际状态上，而不是绑定在一个固定的日历日期上，才能让通知本身保持诚实。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;藏在一个永久性 flag（从来不会被彻底移除的那种）后面的功能，会不会有拿到一条公开体验日志记录的那一天?&lt;/strong&gt;
会，一旦它达到了这个产品语境下&amp;quot;全面可用&amp;quot;所代表的那个状态就会有，哪怕这个 flag 本身出于运营上的原因，会永远留在代码里。体验日志记录关心的，是这个功能对读者来说到底能不能用，而不是这种可用性到底是通过什么实现细节达成的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果 flag 被移除了，而这个功能是被彻底砍掉、而不是被真正发布出去的呢?&lt;/strong&gt;
那属于一次拒绝，而不是一条发布通知，它值得跟任何其他一次拒绝同样的用心对待。&lt;a href=&quot;https://changeloop.dev/blog/zh/declining-feature-requests/&quot;&gt;该怎样拒绝一个功能请求&lt;/a&gt; 讲解了那条消息到底该说些什么；诚实地关闭一个反馈闭环，有时候意味着用一句&amp;quot;不做&amp;quot;来关闭它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Feature flag 发布说明需要一套跟普通条目不一样的模板吗?&lt;/strong&gt;
不需要换模板，只需要在发布之前多加一道检查关口：核对的是提出请求的那个账户的 flag 状态，而不只是核对代码有没有合并，并且把这条记录一直压着，直到那道检查通过为止。除此之外，条目的其他一切，措辞、长度、FAQ 的规范，都跟任何一条普通的发布说明完全一样。&lt;/p&gt;
</content:encoded></item><item><title>功能请求应该怎样追踪，才不会把它们弄丢掉</title><link>https://changeloop.dev/blog/zh/feature-request-tracking/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/feature-request-tracking/</guid><description>功能请求追踪通常只有两种失败方式：请求无处可去，或有了去处却没人回头看。本文说明能扛住这两种失败的机制、好用的分诊标签，以及如何决定优先级并做好闭环。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;功能请求的追踪，几乎总是以两种方式之一失败。要么请求根本没有地方可去，于是就散落在收件箱和 Slack 讨论串里，一条一条地被遗忘；要么有一个地方可以去，可是再也没有人回头去看，于是就一整批一起被遗忘。一套真正管用的机制，必须同时扛住这两种失败：它需要一个所有请求都会落地的单一地点，也需要一个下个月还会有人重新打开这个地点的理由。&lt;/p&gt;
&lt;h2&gt;功能请求到底是从哪些地方来的&lt;/h2&gt;
&lt;p&gt;来自比大多数追踪系统所设想的更多的渠道。一张包含了&amp;quot;要是能这样就好了&amp;quot;这句话的工单。一条留在公开路线图上的评论。一通销售电话，客户在电话里明确说出了阻碍成交的那一件事。产品内部的一个小组件。每个渠道都有各自的负责人，也有各自的工具，而正是因为这样，请求才会四处分散：工单队列和产品团队的待办清单，几乎从来都不是同一套系统，而一条只到达了其中一个渠道的请求，实际上也就只到达了一个部门而已。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;来源&lt;/th&gt;
&lt;th&gt;通常的负责人&lt;/th&gt;
&lt;th&gt;最容易在哪里消失&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;支持工单&lt;/td&gt;
&lt;td&gt;支持团队&lt;/td&gt;
&lt;td&gt;被标记为已解决就关闭了，再也没人回头看&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;销售电话&lt;/td&gt;
&lt;td&gt;销售 / 客户成功&lt;/td&gt;
&lt;td&gt;一个产品团队里没人会去读的 CRM 字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;产品内的组件&lt;/td&gt;
&lt;td&gt;产品团队&lt;/td&gt;
&lt;td&gt;提交了表单，却没有任何后续跟进&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;路线图上的评论&lt;/td&gt;
&lt;td&gt;无论是谁做的这个路线图&lt;/td&gt;
&lt;td&gt;评论串本身&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;社交媒体 / 评价&lt;/td&gt;
&lt;td&gt;市场营销，或者根本没人管&lt;/td&gt;
&lt;td&gt;被截了一次图，然后就没有然后了&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;为每一个渠道各自建一个接收表单是不管用的，因为根本没人会真正采用它。真正有用的做法，是让每一个渠道最终都汇入同一个目的地，哪怕一开始这种汇入方式，只是每天花五分钟做手动的复制粘贴，直到它被自动化为止。&lt;/p&gt;
&lt;h2&gt;到底是什么真正拖垮了功能请求追踪&lt;/h2&gt;
&lt;p&gt;几乎总是两件事。第一件，是缺少一个明确的去处：请求在它到达的那个渠道里就地得到了回应，却从来没有在任何地方被持久地记录下来，于是同一个请求即便来自三个不同的客户，看起来也只是三条互不相关、各自孤立的一次性回复，而不是一个明确的信号。第二件，出现得更频繁，是一个塞满了、也就没人再去读的去处。一份没有任何筛选机制、堆了 400 行的表格，早就已经不再是一套追踪系统了，它只是一个碰巧还能被编辑的档案库而已。&lt;/p&gt;
&lt;p&gt;第二种失败反而更危险，因为它看起来就像追踪机制在正常运转。请求确实被记录下来了。表面上一切都没什么问题，直到有人问出&amp;quot;到底有多少人要求过 X&amp;quot;，而诚实的回答只能是&amp;quot;我们得把这 400 行全都读一遍才能知道&amp;quot;。&lt;/p&gt;
&lt;h2&gt;一条功能请求真正应该记录下来的是什么&lt;/h2&gt;
&lt;p&gt;要足够详细，才能让后来的人不用重新去读原始消息，就能回答三个问题：到底提出了什么请求，如果可能的话，尽量用提出请求的人自己的原话；是谁提出的，如果最后的答案是&amp;quot;我们已经把它做出来了&amp;quot;，又该怎么联系到她；以及需要知道些什么，才能判断这到底是一个普遍的请求，还是一次孤立的个案。逐字的引用，永远比转述更有价值，因为无论是谁负责分诊这条请求，他写出的转述里其实已经带上了他自己的理解，而恰恰正是这份理解，是六个月之后的第二个人再也无法核实的东西。&lt;/p&gt;
&lt;h2&gt;哪些标签才真正值得打&lt;/h2&gt;
&lt;p&gt;两种，各自回答不同的问题。&lt;strong&gt;类型&lt;/strong&gt;标签把一条功能请求和一份缺陷报告区分开来，因为两者需要不同的负责人、不同的时间线，把它们混在同一条队列里，只会让最大声的抱怨挤到请求前面去。&lt;strong&gt;优先级&lt;/strong&gt;标签保持在 low、medium、high 这样一个很小的集合里，把&amp;quot;正挡着某个人没法使用产品&amp;quot;和&amp;quot;有了会挺不错&amp;quot;区分开来，因为这两者本该获得截然不同的响应速度，而任何一方都不该继承另一方的节奏。把&lt;strong&gt;类型&lt;/strong&gt;标签贴对，前提是这条请求真的就是它自称的那样；&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-vs-bug-report/&quot;&gt;一条功能请求实际上可能是一份缺陷报告&lt;/a&gt;讲的正是客户自己的措辞把这个标签引向错误方向的情况。&lt;/p&gt;
&lt;p&gt;自动分诊可以在请求到达的那一刻，同时打上这两种标签。在 changeloop 里，一条来自小组件的提交，会在同一个步骤里获得 &lt;code&gt;feature-request&lt;/code&gt; 或 &lt;code&gt;bug&lt;/code&gt; 标签，以及一个 &lt;code&gt;priority:low|medium|high&lt;/code&gt; 标签，再加上一个 &lt;code&gt;from-widget&lt;/code&gt; 标签，让来源不用打开这条记录就能被看到。光是这样，就足以把整个待办清单从要花一下午才能筛完，缩短到一分钟就能筛完：把这个月所有从小组件来的高优先级功能请求都给我列出来。&lt;/p&gt;
&lt;p&gt;一旦有了公开路线图，第三种标签就开始变得值得使用了：一个提出请求的人自己就能查到的状态。&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;公开路线图&lt;/a&gt;完整讲解了 planned、building、shipped 这几种状态；简单来说，这个标签会把一条私有的队列，变成提出请求的人不用再重新问一遍，就能自己查到的东西。&lt;/p&gt;
&lt;h2&gt;该怎么决定接下来要做什么&lt;/h2&gt;
&lt;p&gt;先分组，再计数。十条用不同措辞表达的、其实是同一种底层能力的请求，堆在表格里看起来就是十行散乱的数据，可一旦被归到同一组，它们就会变成一个很强的信号，而这种归类往往才是缺失的那一步，计数从来都不是。没有分组、只是简单堆出来的原始数字，往往会奖励那个名字最抓人眼球的功能，而不是那个背后真正有最大实际需求的功能。&lt;/p&gt;
&lt;p&gt;按谁提出了请求来加权，而不只是按有多少人提出了请求。一个即将续约的客户提出的请求，和一个刚注册试用的账户提出的同一个请求，紧迫程度完全不一样，而一套为了追求一个干巴巴的数字，就把这层背景信息直接丢掉的追踪系统，优化的其实是那个最容易计算的数字，而不是那个最有用的数字。&lt;/p&gt;
&lt;p&gt;这里做出的每一个决定，同样也会产生一批被淘汰的请求，而这些请求同样值得一个回应；&lt;a href=&quot;https://changeloop.dev/blog/zh/declining-feature-requests/&quot;&gt;该怎样拒绝一个功能请求&lt;/a&gt; 讲解了该对那些没能通过的请求，说些什么才合适。分组和加权只解决了&amp;quot;接下来该做什么&amp;quot;这个问题的一半；&lt;a href=&quot;https://changeloop.dev/blog/zh/prioritizing-feature-requests/&quot;&gt;功能请求优先级排序&lt;/a&gt; 讲解了真正管用的几种框架、RICE、按收入加权、原始数字，以及每一种各自在哪里会失灵。&lt;/p&gt;
&lt;h2&gt;等到某个东西真正发布之后，该怎么闭环&lt;/h2&gt;
&lt;p&gt;这是追踪系统最常跳过的一步，也是提出请求的人真正会注意到的一步。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭环：从客户反馈到告知客户&lt;/a&gt;完整讲解了背后的机制；这里要说的重点是，闭环这件事，只有在原始请求始终和提出它的人保持关联的情况下，才能真正生效。一份从 GitHub issue 构建出来的功能请求模板，把提出请求的人的身份直接挂在这个 issue 本身上，而不是埋在某一条评论里，正是这一点，才让一条自动发出的&amp;quot;已发布&amp;quot;通知成为可能，而不必依赖某个人凭记忆去手动发送它。&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-template/&quot;&gt;功能请求模板&lt;/a&gt;展示了具体的模板内容，也说明了每一个字段到底是做什么用的。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;追踪功能请求应该用什么工具?&lt;/strong&gt;
团队本来就已经每天都在看的东西，胜过任何一个没人会打开的专用工具。如果工程团队本来就活跃在 GitHub 上，issue 追踪器就会用得很顺手；如果产品团队本来就活跃在某个地方，一块轻量级的看板也会用得很顺手。工具本身其实没有那么重要，重要的是它到底会不会被重新打开。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎样才能避免功能请求被重复记录?&lt;/strong&gt;
在按措辞分诊之前，先按底层能力分组。在创建一条新请求之前，先在已有的请求里搜索一遍，这一步能拦下大部分重复；再加上每月一次的分组整理，能把剩下的也清理掉。&lt;a href=&quot;https://changeloop.dev/blog/zh/duplicate-feature-requests/&quot;&gt;合并重复请求却不丢失原来的声音&lt;/a&gt;讲的是分组本身完成之后，该拿那些措辞怎么办，这样合并才不会悄悄把请求收窄成最先到达的那份提交问过的东西。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;是不是每一条功能请求都应该得到回复?&lt;/strong&gt;
每一条都应该得到一个确认，哪怕很简短，但并不是每一条都需要立刻做出决定。一个可见的状态，比如一个提出请求的人自己就能查到的路线图标签，就能替代掉团队原本需要一条一条单独回复的那大部分工作。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;功能请求追踪和公开路线图之间有什么区别?&lt;/strong&gt;
追踪是每一条请求的内部记录，包括那些永远不会被真正发布出来的请求。公开路线图则是团队愿意公开承诺的那一部分子集，并配有一个提出请求的人不用再重新问一遍，就能直接看到的状态。&lt;/p&gt;
</content:encoded></item><item><title>支持工单对功能请求：你到底应该相信哪一个</title><link>https://changeloop.dev/blog/zh/feedback-signal-quality/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/feedback-signal-quality/</guid><description>支持工单和功能请求看板衡量的是两种不同的信号，来自两类不同的用户。把一个渠道的激增与另一个渠道的激增直接等同，只会得出自信却完全错误的优先级排序。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;功能请求看板捕捉的是用户有时间坐下来、把自己想要什么说清楚的时候，主动提出的东西。支持工单
捕捉的是用户此时此刻卡在哪里，往往还带着一点恼火，往往还缺乏把背后那个真实诉求干净利落地表
达出来的词汇。这两者都是真实的信号，而只盯着其中一个看的团队，最终会自信满满地去解决一个
错误的问题，因为每一个渠道都会系统性地过度代表某一类用户和某一类需求。&lt;a href=&quot;https://changeloop.dev/blog/zh/prioritizing-feature-requests/&quot;&gt;给功能请求排优先
级&lt;/a&gt;讲的是给已经上了看板的东西排名；这篇文章讲的是那
个介于&amp;quot;到底有没有上看板&amp;quot;和&amp;quot;只会以支持工单的形式出现&amp;quot;之间的差距。&lt;/p&gt;
&lt;h2&gt;为什么同一个底层问题会出现在一个渠道里，却不出现在另一个渠道里&lt;/h2&gt;
&lt;p&gt;因为这两个渠道的激活成本不一样，而这个成本的高低，决定了谁能跨过它。提交一条功能请求需要
主动性：用户必须相信这个请求值得被说清楚，还要找到那个看板，写点条理清楚的东西，这个过程
本身就筛选出了那些已经投入到产品里、既参与又有耐心的用户。相比之下，提交一张支持工单几乎
不需要任何主动性，往往只是在一项任务进行到一半时点一下&amp;quot;帮助&amp;quot;而已，这意味着它捕捉到的是当下
就感到沮丧的用户，包括那些永远都懒得去碰一个功能请求看板的人。产品里一个真实存在的缺口，
完全可能在功能看板上悄无声息，却在支持渠道里吵得沸沸扬扬，仅仅是因为遇到它的那批用户，恰
恰是最不可能去提交一条正式请求的人。&lt;/p&gt;
&lt;h2&gt;一个缺失功能的工单量，跟它得到的投票数，意味着同一件事吗&lt;/h2&gt;
&lt;p&gt;不，因为它们衡量的是不同条件下的不同群体。一条获得一百票的功能请求，代表的是一百个愿意花
时间去找到并支持一条已有请求的人，这是一个持久、深思熟虑的需求的强烈信号。而在同一时期内，
关于同一个底层缺口提交的一百张支持工单，代表的很可能是一批当下正撞上一堵墙的用户，其中有
些人一旦当下这点摩擦过去了，可能就会把这件事彻底忘掉。把这两者都当成同等的&amp;quot;一百个人想要
这个&amp;quot;信号来对待，会高估工单量的分量，因为工单生成起来很便宜，而投票不是。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;功能请求看板&lt;/th&gt;
&lt;th&gt;支持工单&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;提交需要主动性&lt;/td&gt;
&lt;td&gt;几乎不需要&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;捕捉深思熟虑、持久的需求&lt;/td&gt;
&lt;td&gt;捕捉当下这一刻的沮丧&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;偏向那些既参与又有耐心的用户&lt;/td&gt;
&lt;td&gt;捕捉那些永远都不会去用看板的用户&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;投票数是一个真实的承诺信号&lt;/td&gt;
&lt;td&gt;工单数反映的是摩擦，未必总是渴望&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;一个功能有支持工单、但看板上几乎没有投票，这意味着什么&lt;/h2&gt;
&lt;p&gt;往往意味着这个请求确实存在，只是遇到它的那批用户不知道有这个看板，不相信投票会改变什么，
或者遇到这个问题的频率太低，懒得为了正式登记它而换个渠道。这恰恰就是一个功能请求看板在结
构上会漏掉的那批人群，这里低下的投票数是一种测量缺口的证据，而不是需求低的证据。与其不信
任工单，不如把围绕一个缺失功能形成的一批支持工单集群，当成一个独立的信号，由你自己代表用
户把它登记到看板上，这样它就不会对那些只凭投票数排优先级的人保持不可见。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;看板上读起来是低优先级：
&amp;quot;Export to CSV&amp;quot;：6 个月里 4 票

支持渠道讲的是另一个故事：
&amp;quot;Export to CSV&amp;quot;：同一时期 31 张工单，每一张来自
不同的账户，每一张都以&amp;quot;目前尚不支持，我们会转达
这条反馈&amp;quot;关闭
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;支持工单的激增，是不是总是意味着底层问题就是一个缺失的功能&lt;/h2&gt;
&lt;p&gt;不是，而这正是两个渠道有可能朝相反方向误导你的地方。工单激增同样常常是由一个围绕着某个已
有功能的、令人困惑的界面，一个 bug，或者一次没有配上足够说明就上线的变更所引起的，而这些
情况没有一个能靠造一个新东西来解决。把每一次工单激增都读成&amp;quot;用户想要一个我们没有的功能&amp;quot;，
会做出一份塞满了各种东西的路线图，而那些东西其实原本只是文档缺口或者伪装成别的样子的易用
性问题。支持工单会告诉你摩擦在哪里；但它本身并不会告诉你解决方案到底是一个新功能、一次界
面改动，还是一篇更好的帮助文章，而把这些混为一谈，只会把工程时间浪费在错误的解决方案上。&lt;/p&gt;
&lt;h2&gt;在决定要构建什么的时候，这两种信号到底应该怎么真正结合起来使用&lt;/h2&gt;
&lt;p&gt;用工单去找出摩擦到底在哪里，再用功能请求看板，加上在看板信息稀薄的地方做一些直接接触，去
确认那个真正想要的结果到底长什么样。一批工单集群能识别出一个真实的、被切身感受到的问题；
但它很少能精确到足以据此直接开工构建的程度，因为一个在支持对话里感到沮丧的用户描述的是症
状，而不是规格。而功能请求看板，当它在同一个底层问题上积累了足够多的投票时，往往能承载更
多&amp;quot;这东西到底怎样才算真正满足了需求&amp;quot;这方面的细节，因为写一条请求本身，就已经是在明确表达
自己想要什么，而不只是报告哪里出了问题。&lt;/p&gt;
&lt;h2&gt;支持坐席是否应该自己把工单登记成功能请求&lt;/h2&gt;
&lt;p&gt;应该，而这正是弥合这两个渠道之间那道缺口，杠杆效应最大的单一改进。一个能认出某张工单其实
是一条伪装过的功能请求的坐席，与其只是把它解决掉然后继续往下处理，不如代表客户把它登记到
看板上，这样就直接弥合了那道测量缺口，而不必要求客户自己去发现并使用第二个渠道。但这只有
在登记这件事对坐席来说只需要几秒钟、而不是几分钟的时候才能成立，这样做这件事的摩擦，才会
低于干脆关掉工单、转向下一张的摩擦。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;如果功能请求的投票全都来自同一个账户或同一个团队，是否应该打折计算?&lt;/strong&gt;
应该，按不同的账户或组织来加权，而不是按原始投票数来算，因为来自同一家公司五个人的五票，
代表的是一个客户的优先级，而不是五个独立的需求确认。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个在工单里大量出现、但几乎没有投票的功能，值得去构建吗?&lt;/strong&gt;
往往值得，前提是这些工单量确实来自不同的账户，而且那个底层需求已经被确认过，而不是被假设
出来的；把低投票数当成看板激活成本带来的一种测量产物，而不是需求不真实的证据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎样才能一眼分辨出一张界面困惑工单和一张真正缺失功能的工单?&lt;/strong&gt;
看看它的解决方式，到底是在解释一个已有的能力，还是在为一个缺失的能力道歉。&amp;quot;哦，其实它就在
那里&amp;quot;这种解决模式，指向的是一个界面或可发现性的问题；而&amp;quot;这个我们目前还不支持&amp;quot;这种模式，指
向的才是一个真实的缺口。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在支持量非常小的情况下，这个区分同样那么重要吗?&lt;/strong&gt;
机械意义上没那么重要了，因为少量的工单本来就很容易一张张单独去读，用不着做汇总分析；但那
个底层的偏差，工单会过度代表沮丧的用户、而低估有耐心的用户，在任何规模下都是存在的，即便
你自己亲自读每一张工单，这一点也依然值得记在心里。&lt;/p&gt;
</content:encoded></item><item><title>一条功能请求，实际上很可能是一份缺陷报告</title><link>https://changeloop.dev/blog/zh/feature-request-vs-bug-report/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/feature-request-vs-bug-report/</guid><description>一张要求新增设置项的支持工单，很可能只是在绕开一个隐藏的缺陷。贴错标签会把它送到错的负责人和队列，浪费优先级信号，还拖慢真正的修复。本文讲如何分辨两者。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&amp;quot;你们能不能加一个设置项，把导出上限调高一点？&amp;quot;读起来像是一条功能请求，大多数分诊系统也会当场就这么打标签。有时候确实是。但有时候，导出功能在低于文档里写明的上限的某个数字上就已经失败了，原因其实是一个缺陷，而看不到代码的客户，只能凭自己能想到的最合理的说法编出一个解决方案：给我一个更大的数字，说不定就能行了。&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;哪些标签才真正值得打&lt;/a&gt;讲的是那个把 backlog 分成功能请求和缺陷的类型标签；而这正是客户自己的措辞把这个标签引向错误方向的一种情况，判断出错的代价，是慢慢滑向一个塞满了请求、可一旦挖到底下就会发现根本没人真心想要的 backlog。&lt;/p&gt;
&lt;h2&gt;一条实际上是缺陷的功能请求，看起来会是什么样子&lt;/h2&gt;
&lt;p&gt;它说出的是一个绕开问题的办法，而不是问题本身。一条真正的功能请求，通常描述的是产品目前完全不支持的一种结果：&amp;quot;让我把这个安排到以后再做&amp;quot;，&amp;quot;加一个深色模式&amp;quot;，诸如此类。而一个被分错类的缺陷，描述的是一个具体的数字、阈值或行为，听起来像是缺了某个设置，但其实是个症状：&amp;quot;把超时时间调长一点&amp;quot;，&amp;quot;加一个重试选项&amp;quot;，&amp;quot;让我一次能导出更多行&amp;quot;。它的信号在于，提出请求的人建议的是一种具体实现、一个设置、一个开关、一次覆盖，而不是描述一个目标，因为她已经按照文档所写去试过那个功能了，而它并没有做到文档说它应该做到的事。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;信号&lt;/th&gt;
&lt;th&gt;功能请求&lt;/th&gt;
&lt;th&gt;伪装成功能请求的缺陷&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;提出请求的人描述的是什么&lt;/td&gt;
&lt;td&gt;产品目前做不到的一个结果&lt;/td&gt;
&lt;td&gt;她想改动的一个参数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文档记录的行为是否已经覆盖了这一点&lt;/td&gt;
&lt;td&gt;没有，确实是缺失的&lt;/td&gt;
&lt;td&gt;有，只是没有按文档所说的那样运作&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;投入更多精力，请求会不会自己消失&lt;/td&gt;
&lt;td&gt;不会&lt;/td&gt;
&lt;td&gt;有时候会，如果这个缺陷本来就依赖某个阈值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;应该被路由到哪里&lt;/td&gt;
&lt;td&gt;产品 backlog&lt;/td&gt;
&lt;td&gt;缺陷队列&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;这件事为什么比听起来更重要&lt;/h2&gt;
&lt;p&gt;因为这两条队列有着不同的负责人、不同的时间线、不同的成功标准，而一个被归档成功能请求的缺陷，会被拿去和其他功能请求一起排优先级，跟真正的产品缺口抢注意力，而不是按照一个缺陷本该得到的时间线被修复。&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;怎么追踪功能请求&lt;/a&gt;讲过，把缺陷和功能混进同一条队列，会让最大声的抱怨挤到真正的请求前面去；而一条暗地里其实是缺陷的功能请求，造成的是反方向的伤害——它会一直留在产品 backlog 里，为一个&amp;quot;功能&amp;quot;不断积累投票，而这个&amp;quot;功能&amp;quot;本该在底层缺陷修好的那一刻就消失，结果白白浪费了每一个读这份 backlog 的人的优先级排序信号。&lt;/p&gt;
&lt;h2&gt;当客户自己的措辞把方向指错时，该怎么分辨&lt;/h2&gt;
&lt;p&gt;去问她原本期望发生什么，而不是问她想让你们加什么。&amp;quot;导出功能卡在 500 行，我需要 2000 行，你们能不能把上限调高一点&amp;quot;，听起来就是一条要求调高上限的功能请求，直到追问一句&amp;quot;500 是文档里写的上限吗&amp;quot;，才发现文档里记录的数字其实是 5000，而导出功能提前就失败了。光是这一个问题——她原本期望什么，对比实际发生了什么——就完成了分类工作的大部分，因为一条真正的功能请求根本没有一段它没达到的文档化行为；没有什么可以&amp;quot;期望&amp;quot;的，因为那种能力本来就还不存在。&lt;/p&gt;
&lt;h2&gt;应该由支持人员还是工程师来做这个判断&lt;/h2&gt;
&lt;p&gt;支持人员先做第一轮判断，因为工单最先到她们手里，但这个标签应该容易改、贴错了代价也小，而不该是一锤定音、把这一条永远钉死在错误队列里的一次性决定。一个轻量的二次检查——由一位工程师每周翻一遍新打上&amp;quot;功能请求&amp;quot;标签的条目，找那些闻起来像是缺陷伪装的——就能抓住那些没有代码库背景的支持人员根本认不出来的情况。这不需要走正式流程，更像是花五分钟扫一眼，而不是一整套评审流程。&lt;/p&gt;
&lt;h2&gt;找到真正的缺陷之后，闭环的方式会变吗&lt;/h2&gt;
&lt;p&gt;会，而且能发出一条更好的消息。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭合客户反馈循环&lt;/a&gt;讲的是在一个请求上线时通知提出请求的人；一个被重新归类的缺陷，能得到这条消息的一个更好版本，因为&amp;quot;我们找到并修复了背后的那个缺陷&amp;quot;听起来像是有能力的表现，而&amp;quot;我们做出了你要求的那个功能&amp;quot;却只有在纯属巧合的情况下才会是真的——因为那条真正的功能请求，也就是一个真正更高的导出上限，一旦缺陷消失、原本 5000 行的上限已经够用了，可能就永远不会被真正做出来了。&lt;/p&gt;
&lt;h2&gt;如果这种误判从来没被发现，会发生什么&lt;/h2&gt;
&lt;p&gt;Backlog 会被那些看起来像真实需求、实际上并不是的请求填满，而基于这份 backlog 做出的优先级决策，会连带继承这种扭曲。一个有四十票的&amp;quot;功能&amp;quot;，实际上很可能是四十个人碰到了同一个缺陷，而如果真的把这条字面意思上的请求做出来——一个用来调高一个从来就不是真正约束的上限的设置——只会带来一堆不解决任何问题的复杂度，与此同时，那个底层缺陷还会继续从那些尚未找到这个话题的客户那里，源源不断地制造出新的&amp;quot;功能请求&amp;quot;。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;值不值得加一个正式步骤，把每一条功能请求都拿去和已知缺陷对照一遍?&lt;/strong&gt;
不需要一个正式步骤，更像是一种习惯：不管是谁在给一条新的功能请求分诊，都应该在打标签之前先问一句&amp;quot;文档记录的行为是不是已经声称自己在做这件事了&amp;quot;，因为光是这一个问题，就能在不增加流程负担的前提下，抓住大多数分类错误。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果找到缺陷之后，客户依然坚持这是一条功能请求，该怎么办?&lt;/strong&gt;
向她解释你们发现了什么，以及为什么她提议的那个设置项，在缺陷修好之后就不再需要了。大多数客户请求一个绕开办法，是因为她们以为真正的修复根本不存在，而不是因为她们特别想要那个设置项本身。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一条被重新归类的条目，会不会丢掉它作为功能请求积累下来的投票或评论?&lt;/strong&gt;
应该保留下来，并且保持可见，因为那些投票正是当初牵引出这个缺陷的证据，而把这条痕迹藏起来，只会让下一次、在另一张工单上，同样的误判更难被发现。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这种情况会反过来发生吗，一份实际上是功能请求的缺陷报告?&lt;/strong&gt;
比较少见，但确实会发生：&amp;quot;这个东西坏了&amp;quot;有时候意味着&amp;quot;这个东西没有按我以为它该有的方式运作&amp;quot;，而这其实是一种缺失的能力，不是一个缺陷。同样的那个问题——她原本期望什么，对比文档实际写的是什么——在这个方向上也同样能起到分类的作用。&lt;/p&gt;
</content:encoded></item><item><title>Git 标签、发布记录和你的体验日志该怎样对应</title><link>https://changeloop.dev/blog/zh/git-tags-releases-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/git-tags-releases-changelog/</guid><description>Git 标签、一次发布和一条体验日志记录，是同一事件的三份记录，用途和读者各不相同。把它们混为一谈，正是体验日志偏离实际发布的原因，本文说明如何对应。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Git 标签、一次发布，还有一条体验日志记录，是同一个事件的三份不同记录，而把它们混为一谈，会让体验日志悄悄偏离实际发布出去的东西。标签标记的是一个提交。发布把这个标签和一堆构建产物、一段描述打包在一起。体验日志记录则用一种即使是仓库之外的读者也能理解的语言，说明到底发生了什么变化。它们通常在时间上离得很近，正因为这样，把它们当成一个步骤而不是三个步骤来处理才会显得那么自然，也正因为这样，这道裂缝往往要等到几个月之后，有人问起&amp;quot;v2.4 到底发布了什么&amp;quot;、而诚实的答案需要一番真正的挖掘时，才会真正显露出来。&lt;/p&gt;
&lt;h2&gt;这三者之间到底有什么真正的区别&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;记录&lt;/th&gt;
&lt;th&gt;存在于&lt;/th&gt;
&lt;th&gt;写给谁看&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Git 标签&lt;/td&gt;
&lt;td&gt;仓库里，作为一个引用&lt;/td&gt;
&lt;td&gt;任何会去检出那个具体提交的人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发布&lt;/td&gt;
&lt;td&gt;代码托管平台（GitHub、GitLab）&lt;/td&gt;
&lt;td&gt;任何会去下载某个构建版本的人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;体验日志记录&lt;/td&gt;
&lt;td&gt;产品自己的体验日志&lt;/td&gt;
&lt;td&gt;任何在使用这个产品的人，而不只是这个仓库&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;标签是三者里最偏机械化的那一个：执行一句 &lt;code&gt;git tag v2.4.0&lt;/code&gt; 就完事了，完全不要求有任何东西去解释它里面到底装了什么。发布会加上一段描述，通常还会带上可供下载的构建产物，而它面向的对象，依然是那些知道发布页面是什么的开发者。体验日志记录是三者里唯一一个专门写给那种可能永远都不会打开这个仓库的读者看的东西，正因如此，它才是最需要编辑投入心思的那一份，也是在截止日期的压力下最容易被跳过的那一份。&lt;/p&gt;
&lt;h2&gt;是不是每一个 git 标签都需要配一条体验日志记录&lt;/h2&gt;
&lt;p&gt;不需要，把两者当成一一对应的关系，是一个很常见的错误。一个标签可能标记的是一个内部里程碑、一个候选发布版本，或者一个根本不会触达大多数用户的紧急修复；这些情况都不一定需要一条公开的记录。判断标准和判断某样东西到底该不该被收进体验日志里的标准完全一样：一个用户或者调用方，会不会注意到这个变化，或者在不在乎这个变化。大多数标签都能通过这个测试。但有些标签，比如那种纯粹只是为了触发一次 CI 流水线而创建的标签，永远都不会通过。&lt;/p&gt;
&lt;h2&gt;是不是每一条体验日志记录都需要拥有自己专属的标签&lt;/h2&gt;
&lt;p&gt;不总是这样，而这正是持续部署的团队和发布带版本号软件包的团队开始分道扬镳的地方。一个每天要部署好几次的 SaaS 产品，完全可以把多次部署归到同一条带日期的体验日志记录下面，而不需要每次部署都配一个一一对应的标签；而一个发布到软件包注册中心的库，通常需要为每一个已发布的版本配一个标签。Go 模块和 Swift Package Manager 直接从标签本身解析版本；在 npm 或 PyPI 上，注册中心保存着已发布的版本，而标签是任何人把那个版本对应回源代码的方式。一个拥有多个各自独立管理版本号的包的仓库，必须按包来做这个决定，而不是给整个仓库一次性拍板；&lt;a href=&quot;https://changeloop.dev/blog/zh/monorepo-changelogs/&quot;&gt;Monorepo 的体验日志&lt;/a&gt; 讲解了标签前缀和体验日志的范围，为什么应该跟着包的边界走，而不是跟着文件夹的边界走。&lt;a href=&quot;https://changeloop.dev/blog/zh/semantic-versioning-changelog/&quot;&gt;语义化版本控制，应该怎样配合你的体验日志&lt;/a&gt; 完整讲解了版本号本身应该怎样对应到体验日志的分类上；而标签，正是让一个版本号能够对照真实代码被验证的那套机制。&lt;/p&gt;
&lt;h2&gt;一份发布说明，到底应该和体验日志记录之间是什么关系&lt;/h2&gt;
&lt;p&gt;它们可以是同一段文字，但前提是两者面对的读者真的完全一致，而这种情况其实比看上去要少见得多。代码托管平台上的发布页面，几乎清一色只有开发者会去读；如果一个产品同时还有非技术用户在读体验日志，那么把发布说明原封不动地复制过去，只会把内部术语和面向代码的措辞，送到一个其实需要通俗语言版本的读者面前。最干净的做法是：把体验日志记录本身，当作面向读者的主要成果来写，然后让发布说明要么直接链接回这条记录，要么给那些本来就已经习惯了那种风格的读者，留一份更短、更偏技术性的摘要。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Release v2.4.0（GitHub，面向开发者）
把报表流水线升级到了新的聚合引擎。面向客户的摘要请见体验日志：
https://example.com/changelog#v2.4.0

## 2026-09-07（体验日志，面向客户）
### Added
- 报表现在即使对于超过一百万行数据的账户，也能在一秒之内加载
  出来。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一次发布，两份文档，各自用各自的措辞，写给各自真正的读者。&lt;/p&gt;
&lt;h2&gt;体验日志记录到底是从哪里来的&lt;/h2&gt;
&lt;p&gt;来自两个不同的起点，而大多数真实存在的流水线，其实都是这两者的混合。它可以在打标签的那一刻，从提交信息里自动生成出来，这种方式很快，而且绝不会漏掉任何一个已经合并的拉取请求；&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;从 Conventional Commits 到体验日志&lt;/a&gt; 完整讲解了这整条流水线。或者，它也可以完全独立于标签之外，靠人工手写出来，时间点对齐的是一个功能被认定为完成的那一刻，而不是代码被合并的那一刻。自动生成出来的记录足够一致，但会原封不动地继承每一条含糊不清的提交信息；而手写的记录读起来更清楚，但需要有人真正花时间去把它写出来。大多数采用自动化的团队，最终还是会在生成出来的文本变成公开记录之前，保留一道轻量的编辑环节，而不是直接把未经处理的原始输出展示出来，这和 &lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog，实践版&lt;/a&gt; 所推荐的自律其实是同一回事，无论那段原始文字最初到底是从哪里来的。&lt;/p&gt;
&lt;h2&gt;当这三者不再同步的时候，到底会出什么问题&lt;/h2&gt;
&lt;p&gt;会失去的，是读者最先查看的那个东西所带来的信任。一个存在、却没有对应体验日志记录的标签，从阅读体验日志那一方的视角来看，就像是那一周什么事都没发生过一样。而一条没有对应标签或者发布的体验日志记录，会让一个正在排查生产环境问题的人，根本没办法检出到那条记录发布时、正在线上真正运行着的那份确切代码。真正的解决办法，不是追求完美的自动化，而是为这种对应关系建立一个单一的事实来源：哪怕只是发布流程自己的一份检查清单，明确规定一次可发布的变更，必须在引入它的那同一个提交或者拉取请求里，同时拿到这三样东西。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;体验日志记录是不是应该从 git 标签里自动生成出来?&lt;/strong&gt;
可以把它当作一个起点，但仅凭一个标签本身，并不携带任何面向读者的描述，它携带的只是一个提交范围。自动化生成必须去读取这个范围内的提交信息本身，而不能只依赖标签的存在，才能真正产出一些有用的东西。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果我们并不给每一次发布都打标签，会怎么样?&lt;/strong&gt;
那么体验日志记录就会变成主要的记录来源，它依然应该带上日期，如果这个产品有版本号的话，也应该带上版本号，这样即使没有对应的标签，这条记录也依然能成为读者以后可以回头引用的东西。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;预发布标签（比如 &lt;code&gt;v2.4.0-rc.1&lt;/code&gt; 这种）是不是也应该配上体验日志记录?&lt;/strong&gt;
一般来说不应该。候选发布版本是用来做内部测试或者公测的，为它配一条体验日志记录，只会训练读者去期待那些可能根本不会按描述的样子真正发布出去的版本也有自己的记录。把记录留给那些真正达到了正式可用状态的标签就够了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一条体验日志记录是不是可以同时覆盖多个 git 标签?&lt;/strong&gt;
可以，而且对于那些经常打标签的团队来说，往往也应该这样做。把相关的标签归到同一条描述净变化的、带日期的记录下面，而不是按标签逐个发布单薄的记录，把一个功能拆得七零八落，散落在好几次阅读里。&lt;/p&gt;
</content:encoded></item><item><title>对内 API 体验日志：对另一个团队来说，到底什么变了</title><link>https://changeloop.dev/blog/zh/internal-api-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/internal-api-changelog/</guid><description>对外的 API 体验日志面对无法联系的读者，对内的读者可能就在两层楼之外，这改变了它的责任。本文讲这些差别、谁来维护调用方名单，以及和 monorepo 的关系。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这个专题下的其他每一篇文章，都默认调用某个 API 的人身处公司之外：某个客户那边的工程师，一个合作伙伴，或者某个自己找到了文档的人。但很多 API 面对的调用方完全是另一类人，可能就是隔壁办公室、或者两层楼之外的另一个团队，而这彻底改变了体验日志到底该对他们负什么责任这笔账，因为一条 Slack 消息就能触达他们，而客服工单这种东西，通常压根就不会被开出来。大多数团队从这一点得出的结论是，对内的 API 根本不需要体验日志。但他们真正需要的，其实是一份完全不一样的体验日志。&lt;/p&gt;
&lt;h2&gt;对内 API 的体验日志，到底跟对外的有什么不一样&lt;/h2&gt;
&lt;p&gt;对内的 API 的读者是可以被直接触达的，这一点恰恰去掉了大多数对外 API 体验日志之所以存在的核心理由：向那些没法逐一联系上的调用方广播消息。拥有一个对内 API 的团队，通常清楚地知道到底是哪些其他团队在调用它，有时甚至精确到具体哪个服务。这让一条有针对性的消息，而不是一份公开的信息流，成了自然而然的默认选择，而这恰恰也是对内 API 最终往往完全没有体验日志的原因：拥有这个 API 的团队通知了自己记得的那两三个团队，然后想当然地以为这样就覆盖了所有人。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;对外 API 体验日志&lt;/th&gt;
&lt;th&gt;对内 API 体验日志&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;谁会读它&lt;/td&gt;
&lt;td&gt;任何外部调用方，大多数情况下没法被直接触达&lt;/td&gt;
&lt;td&gt;一小群通常已知的对内团队&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;默认渠道&lt;/td&gt;
&lt;td&gt;一个页面加一份信息流&lt;/td&gt;
&lt;td&gt;发给调用方团队的一条消息，理想情况下也配一个页面&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最大的风险&lt;/td&gt;
&lt;td&gt;某个调用方完全错过了这条记录&lt;/td&gt;
&lt;td&gt;拥有这个 API 的团队忘记了一个自己都不记得存在的调用方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用什么取代&amp;quot;我们不知道是谁在调用我们&amp;quot;&lt;/td&gt;
&lt;td&gt;什么都没有；只能广泛发布&lt;/td&gt;
&lt;td&gt;一份真正存在、并且持续保持更新的调用方登记表&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;为什么&amp;quot;我们直接通知调用我们的那些团队&amp;quot;这套做法会失灵&lt;/h2&gt;
&lt;p&gt;因为调用方这个集合，从来都不像拥有 API 的团队所记得的那样小、那样固定不变。一个为某一个消费方构建的服务，六个月后会通过一次没人对外宣布过的集成，多出第二个调用方，而拥有这个 API 的团队脑子里&amp;quot;到底是谁在调用我们&amp;quot;这份名单，此刻已经错了，却没有人意识到这一点。这种失败是平常且常见的，是依赖记忆而不是依赖一份记录所必然导致的默认结果，而不是说明谁不够上心。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是破坏性变更&lt;/a&gt; 讲解了该怎样判断一次 API 变更到底算不算破坏性变更；而对内这种情况，还在这个问题之上，叠加了第二个更难的问题：到底该通知谁。&lt;/p&gt;
&lt;h2&gt;对内 API 到底需不需要一个对外风格的体验日志页面&lt;/h2&gt;
&lt;p&gt;通常还是需要的，哪怕主要渠道是直接沟通。有了页面，直接发的消息就有了一个可以指向的地方，通知本身可以保持简短（&amp;quot;&lt;code&gt;/v2/accounts&lt;/code&gt; 出现了破坏性变更，详情见这里&amp;quot;），而不用硬把整段解释塞进一条很快就会被滚动条冲走的聊天消息里。它同时也成了一个新团队、或者一个错过了那条直接消息的团队，在自己的集成出问题、试图搞清楚到底为什么的时候，可以去查的地方。这个页面不需要打磨得多精致，也不需要完全公开；它只需要能被链接，并且比那条宣布它存在的 Slack 讨论串活得更久就够了。&lt;/p&gt;
&lt;h2&gt;到底是谁在真正维护那份调用方名单&lt;/h2&gt;
&lt;p&gt;是拥有这个 API 的团队，而这件事必须被当成一份真正的产出物来对待，而不是靠口口相传的部落知识。最省事的做法，是在 API 自己的仓库里放一个文件，列出一份简短的消费方服务清单，每一条都写明负责人，每次有新的集成被搭建起来时都同步更新，这跟任何一种依赖声明的纪律要求完全一样。另一种做法，也就是每次要做破坏性变更之前都到处去问一圈，能撑到某一次终于有人忘了去问对的那个人为止；而一个对内 API 悄无声息地为某个团队坏掉，虽然是一起比对外事故规模更小的事故，但终究还是一起事故，而且通常是被那个团队自己的值班人员发现的，而不是被这个 API 的拥有者发现的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# consumers.yml
- service: billing-service
  owner: &amp;quot;#team-billing&amp;quot;
  since: 2026-03-01
- service: reporting-pipeline
  owner: &amp;quot;#team-analytics&amp;quot;
  since: 2026-06-14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样一份文件，把&amp;quot;我们到底该通知谁&amp;quot;从一个需要现场回答的问题，变成了一次查表就能搞定的事情。
专门为解决这个问题而生的工具，比如 &lt;a href=&quot;https://backstage.io/docs/features/software-catalog/system-model/&quot;&gt;Backstage 的服务目录&lt;/a&gt;，
出于同样的理由，把每个 API 都建模成带有明确声明的消费方的一等实体：一旦一个组织内部服务的数
量多到一定程度，就没有人的记忆能自己一直保持准确，总得有什么东西来替代记忆、承担起记录的责
任。在动手搭建一套专属工具之前，先看看你们内部已经在用的那套工具的&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;文档&lt;/a&gt;，往往才是
正确的第一步。&lt;/p&gt;
&lt;h2&gt;对内体验日志的记录里，需要包含哪些对外体验日志完全用不上的内容&lt;/h2&gt;
&lt;p&gt;更多运营层面的具体细节，因为读这份记录的人是同一套基础设施里的另一位工程师，她会据此直接采取行动，而不是把它当成一份摘要来读。这个变更到底在哪些环境里已经上线、什么时候上线的，因为对内服务经常要经过一系列外部调用方永远看不到的阶段才能逐步推进。这个变更是否需要消费方那一侧做配置更新，或者更新客户端库，如果存在对应的命令，就直接写出来。还有，因为对内的调用方往往可以直接跟拥有这个 API 的团队协调修复方案，这里应该写一个指名道姓的联系人，而不是一个客服渠道：&amp;quot;如果这东西弄坏了什么，去找 @maria&amp;quot; 这句话放在一条对内记录里完全合情合理，放进一份对外的 API 体验日志里就会显得很奇怪。&lt;/p&gt;
&lt;h2&gt;这套逻辑放到 monorepo 内部的体验日志上，是不是同样成立&lt;/h2&gt;
&lt;p&gt;它让同一个问题变得更尖锐，而不是把这个问题替换掉。&lt;a href=&quot;https://changeloop.dev/blog/zh/monorepo-changelogs/&quot;&gt;Monorepo 的体验日志&lt;/a&gt; 讲解了一个包到底什么时候需要拥有自己独立的体验日志；而一个作为 monorepo 里众多包之一而存在的对内 API，依然需要把它的消费方明确地追踪下来，因为跟调用方共用同一个仓库，并不意味着他们会自动注意到一次变更，除非有什么东西明确提醒他们该去看一眼了。仓库里的物理距离，跟注意力上的距离，从来都不是一回事。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;如果一个纯对内的 API 只有一个调用方，它还需要体验日志吗?&lt;/strong&gt;
几乎不需要，直接给那一个团队发条消息通常就够了。一旦调用方超过一个，或者调用方名单曾经让拥有这个 API 的团队感到意外过哪怕一次，体验日志的价值就体现出来了，因为那正是记忆已经不再可靠的信号。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对内的 API 变更，应该走跟对外变更一样的评审流程吗?&lt;/strong&gt;
措辞可以更随意一些，因为读者是同事而不是外部调用方，但一次变更到底算不算破坏性变更，这个判断在两种情况下都值得被同样认真地对待。对内的调用方，同样有依赖旧行为的生产代码在跑着。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果从来没追踪过，该怎么找出到底是谁在调用一个对内 API?&lt;/strong&gt;
如果消费方登记表从来没维护过，服务器日志或者 service mesh 的流量数据就是最诚实的答案；把这次发现，当成开始维护一份登记表的起点，而不是当成一次性的清理工作来对待。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一条 Slack 消息就够了吗，还是对内的变更依然需要一条正式的体验日志记录?&lt;/strong&gt;
只要不是纯粹的新增功能，两者都需要。消息是能被及时读到的那一部分；记录则是几周后有团队在排查问题、却从没见过那条消息的情况下，依然能够找到的那一部分。&lt;/p&gt;
</content:encoded></item><item><title>对内发布说明：除了客户，还有谁必须知道到底发生了什么</title><link>https://changeloop.dev/blog/zh/internal-release-notes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/internal-release-notes/</guid><description>支持和销售团队常从一头雾水的客户口中，才第一次得知有新功能上线。对内发布说明能解决这个问题，本文讲它该怎么写、谁来写、何时写，以及如何比客户更早送达。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这个专题下的其他每一篇文章，默认读发布说明的人是客户。支持团队、销售团队、客户成功团队其实也在读，或者说也在试着读，但他们当中大多数人，是因为客户先问起来，才知道到底上线了什么。这个顺序本身就是反过来的，而这也恰好是大多数公司默认的做法，因为发布流程一到对外说明发出去的那一刻，就已经算结束了，从来没有人为一小时后必须回答相关问题的这群人，专门搭建过第二个、更小的步骤。&lt;/p&gt;
&lt;h2&gt;对内发布说明到底是什么，跟对外说明比起来有什么不一样&lt;/h2&gt;
&lt;p&gt;它是一份更短的文档，写给那些早就深度了解产品的人看，告诉他们到底发生了什么变化，以及在他们各自具体的工作里，该拿这个变化怎么办。支持团队的客服代表，根本不需要对外公告那种精心打磨过的措辞；他真正需要知道的，是这个变化现在在产品里到底长什么样、围绕它最有可能冒出来的问题是什么、以及现有的未结工单会不会受到影响。对外说明是在把这个变化卖出去。对内说明则是在把某个人武装起来，让他有能力真正应对这个变化。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;读者&lt;/th&gt;
&lt;th&gt;需要知道什么&lt;/th&gt;
&lt;th&gt;在哪里需要它&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;支持团队&lt;/td&gt;
&lt;td&gt;界面上到底改了什么、最可能冒出来的问题、受影响的未结工单&lt;/td&gt;
&lt;td&gt;他们本来就已经在找答案的地方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;销售团队&lt;/td&gt;
&lt;td&gt;这能为一笔交易打开什么、目前还做不到什么&lt;/td&gt;
&lt;td&gt;他们准备通话内容的地方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;客户成功团队&lt;/td&gt;
&lt;td&gt;该跟现有客户说什么、是谁提出过这个要求&lt;/td&gt;
&lt;td&gt;他们规划主动联系客户的地方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;管理层&lt;/td&gt;
&lt;td&gt;相对于承诺过的内容，到底发布了什么、什么时候发布的&lt;/td&gt;
&lt;td&gt;一份简短、周期性重复的总结，而不是每次发布都要一份&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;为什么对内团队总是很晚才知道有新功能上线了&lt;/h2&gt;
&lt;p&gt;因为发布流程通常都是围绕一份成果来搭建的，要么是对外说明，要么是体验日志里的一条记录，而所有对内该知道的信息，都被默认为读完这一份文档就自然会知道。事实并非如此。支持团队的客服代表，忙着处理眼前那张工单，根本没空为了找背景信息去翻体验日志，而一份写给客户看的说明，往往恰恰漏掉了客服代表真正需要的那个运营细节，比如这个功能到底绑定在哪个套餐上，或者出错的时候错误提示到底长什么样。等到客户真正问起来的时候，客服代表读到的，还是那份客户刚刚读过的同一份公开说明，完全没有任何领先一步的优势。&lt;/p&gt;
&lt;h2&gt;一份对内发布说明，到底该说清楚哪些对外说明不会说的内容&lt;/h2&gt;
&lt;p&gt;那些对外说明有意省略掉的运营细节。到底哪些套餐或账户拥有这个功能。出错的时候到底长什么样，以及遇到这种情况该跟客户说什么。它到底会不会关闭某些未结请求或工单，会关闭哪些，好让处理相关工单的客服代表知道该去核实一下。一旦某个问题超出了这份说明覆盖的范围，团队里到底该由谁来负责。以上这些内容，没有一条属于那份只打算让公司外部的人读一次的对外版本；而这些恰恰正是那个每周要把同一个问题回答四十遍的人，真正需要的东西。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;对内备注：批量 CSV 导出（2026-09-08 上线）

- 仅限 Team 和 Enterprise 套餐使用。Free 和 Pro 套餐没有任何
  变化。
- 常见错误：超过五万行的导出会触发超时；已知问题，修复工作
  单独跟踪中。告诉客户按日期范围筛选。
- 关闭 14 条标记为 `bulk-export` 的未结请求。回复模板在共享
  文档里。
- 负责人：platform 团队，超出这份备注范围的问题都发到
  #platform-eng。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这四行内容，支持团队的客服代表马上就能用上，而其中没有一行会出现在同一个功能的公开体验日志记录里。&lt;/p&gt;
&lt;h2&gt;到底该由谁来写这份说明，什么时候写&lt;/h2&gt;
&lt;p&gt;写对外说明的那个人，通常正是最合适的人选，因为他早已掌握了全部背景信息，但这应该是一次单独的、简短的处理，而不是硬要让一份文档同时服务两类读者。把两者合并在一起，结果要么是一份塞满了内部细节的对外说明，要么是一份打磨得过头、反而没那么实用的对内说明，而实际操作起来，写两份简短的文档，往往比费劲协调一份文档同时服务两类读者要快得多。时机比到底是谁写的更重要：对内说明必须比对外说明更早发出去，哪怕只早几个小时，这样才能确保支持团队永远不会跟客户从同一个地方才知道这个变化。&lt;/p&gt;
&lt;h2&gt;这份说明到底该放在哪里，才能让支持团队在工单出现的那一刻真正找到它&lt;/h2&gt;
&lt;p&gt;放在团队本来就会去查的地方，而不是一份没人有理由主动去打开的独立体验日志里。一个使用共享知识库的支持团队，需要这份说明就放在那里，并且从那些跟产品这部分相关的工单本来就已经打好标签的地方链接过去。一个生活在共享频道里的团队，需要这份说明发布在那个频道里，可以被搜索到，并且恰好在它真正有用的那个时刻出现，而不是被埋在一份大家只翻一遍的每日汇总里。&lt;a href=&quot;https://changeloop.dev/blog/zh/product-update-email/&quot;&gt;定向通知和摘要邮件&lt;/a&gt; 里讲的对外那套模式，在这里同样适用：一份关于某个具体、即将发生的变化的对内备注，应该直接送到团队手上，而不是等一份要等到第一张工单已经出现之后才会送达的每周汇总。&lt;/p&gt;
&lt;h2&gt;它是不是需要跟对外的说明一样严格的审阅流程&lt;/h2&gt;
&lt;p&gt;需要的更少，而这是刻意为之的。对外说明代表着公司在公开场合发声，值得一次仔细的编辑打磨；对内说明存在的意义就是要快、要具体，而如果非要拿同一套打磨标准来要求它，往往恰恰就是团队最后干脆彻底不再写它的真正原因。一份在上线前一小时才发出去、写得快、还有点粗糙的对内备注，胜过一份打磨得很精致、但要等到第二天才送达的说明，那时候第一张支持工单早已带着满肚子困惑先到了。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;对内发布说明，是不是应该走跟对外说明一样的审批流程?&lt;/strong&gt;
不应该。更轻、更快的处理流程，正是它存在的意义所在。要求走同样的审阅，会把一份本该当天就能发出去的对内备注，硬生生拖成下周才发出去的备注，而到那时候，支持团队早就已经在没有它的情况下回答完那个问题了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果没有专职负责对内沟通的角色，对内发布说明到底该由谁负责?&lt;/strong&gt;
由写对外说明的那个人负责，紧接着再单独花一小段时间处理这第二份简短的说明。这不需要单独指定一个负责人，只需要养成一个习惯：不要把对外说明当成一次发布唯一产出的成果。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对内发布说明，是不是需要一份属于自己的体验日志或者归档?&lt;/strong&gt;
一个可以被搜索到的地方，胜过一份没人会去翻的按时间排列的归档。如果支持团队已经有知识库了，这份说明就该放在那里，打上功能标签，而不是放进一份单独的对内体验日志里，那种归档只能帮到那些本来就已经知道上线日期的人。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;小改动跳过对内发布说明，到底有什么风险?&lt;/strong&gt;
小改动恰恰正是支持团队最容易在毫无预警的情况下被问到的那种问题，因为小改动很少会得到一次全公司范围的公告。发布说明的篇幅大小，应该跟改动本身的大小成比例地调整；绝不应该仅仅因为改动很小，就直接降到零。&lt;/p&gt;
</content:encoded></item><item><title>移动应用的发布说明：字数上限到底会砍掉什么</title><link>https://changeloop.dev/blog/zh/mobile-app-release-notes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/mobile-app-release-notes/</guid><description>App Store 和 Play Store 只给几行可见文字，一个链接都不给，网页版的写法在这样的预算下会失灵。本文用真实例子讲如何取舍，别把最重要的一句挤出预览区。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这个专题下关于怎样写发布说明的所有内容，默认前提都是你能完全掌控的一个页面：想要多长就多长，链接能正常点开，格式能正常渲染出来。而一个移动应用的发布说明，却生活在别人家的盒子里。苹果给大约 4000 个字符的空间，但在用户点开&amp;quot;更多&amp;quot;之前，只会展示最前面那几行；谷歌给的空间量级差不多，也存在同样实际的预览问题，而且这两个平台都不会把文字里的链接渲染成可以点击的样子。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;怎样写真正会被读完的发布说明&lt;/a&gt; 里的那些规则依然成立：说清楚到底改了什么、读者到底该做什么，但用来做这件事的空间，只是体验日志页面所允许空间的一小部分，取舍必须是刻意为之的，而不是不小心造成的。&lt;/p&gt;
&lt;h2&gt;看得见的预览区域，实际上到底能装下多少内容&lt;/h2&gt;
&lt;p&gt;最前面的一到两行，具体大约是 80 到 170 个字符，取决于设备和字号大小，在这之后读者才需要点一下才能展开看到更多。这就是发布说明里决定到底会不会有人愿意接着读下去的那部分内容，所拥有的全部预算，这也意味着最重要的那句话必须放在最前面，而不是版本号，不是问候语，也不是分类标题。一条以&amp;quot;本版本更新内容：&amp;quot;开头的发布说明，已经把三分之一的可见空间，花在了四个对读者什么都没说清楚的字上。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;平台&lt;/th&gt;
&lt;th&gt;大致的总字数上限&lt;/th&gt;
&lt;th&gt;&amp;quot;更多&amp;quot;之前的有效预览&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;App Store（iOS）&lt;/td&gt;
&lt;td&gt;约 4000 字符&lt;/td&gt;
&lt;td&gt;2-3 行，大约 80-170 字符&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Play&lt;/td&gt;
&lt;td&gt;每种语言约 500 字符，部分字段更短&lt;/td&gt;
&lt;td&gt;2-3 行，跟 iOS 类似&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;两者都是&lt;/td&gt;
&lt;td&gt;发布说明字段里没有可点击的链接&lt;/td&gt;
&lt;td&gt;不适用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;&amp;quot;现在能做什么、承诺过什么&amp;quot;这条规则，在这么短的篇幅下是不是依然成立&lt;/h2&gt;
&lt;p&gt;依然成立，而且会变得更严格，而不是变成别的东西。每条更新一句话，动词放最前面，不要铺垫：&amp;quot;在设置里就能把数据导出成 CSV 格式。&amp;quot;用三分之一的字数，说出了跟&amp;quot;我们新增了一项功能，用户现在可以把自己的数据导出为 CSV 格式了&amp;quot;完全一样的意思，还赢了。在体验日志页面那种篇幅下，一句稍微啰嗦一点的话，只不过让读者多花半秒钟。而在移动端发布说明这种篇幅下，同样的啰嗦程度，完全可能把整句话都挤到看得见的预览区域之外，结果读者根本看不到那个原本能告诉她到底改了什么的动词。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;不好的写法，把预览区域浪费在了铺垫上：
&amp;quot;我们很高兴为你带来一次充满改进的全新更新！
继续阅读了解详情。&amp;quot;

好的写法，全部价值都在第一行：
&amp;quot;在设置里就能把数据导出成 CSV 格式。深色模式现在
会跟随系统设置了。修复了打开分享链接时的崩溃问题。&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;一条网页版体验日志记录通常会保留的内容，在这里必须被砍掉哪些&lt;/h2&gt;
&lt;p&gt;首先是链接，因为两个应用商店都不会把它渲染成可以点击的样子，所以文字里的一个 URL，就成了一段读者不得不重新手打一遍的死重量。如果这条更新确实需要指向某个地方，那就换成说明在应用里该点哪里：&amp;quot;在设置 &amp;gt; 搜索里可以看到新的筛选项&amp;quot;是能用的写法；&amp;quot;更多内容请见 example.com/blog/filters&amp;quot;在这个界面上根本行不通。其次，任何带条件的、或者只针对特定人群的内容也要砍掉：一条网页版体验日志可以说&amp;quot;如果你在用这个 API，这条跟你有关&amp;quot;，但一条应用商店的更新说明，会同时触达每一个安装了这个应用的用户，所以一句带条件的话，对那 95% 完全用不上这条信息的用户来说，读起来就是噪音。带条件的细节，应该改放进一条应用内消息里，只针对真正跟这件事有关的账户触发。&lt;/p&gt;
&lt;h2&gt;是不是每一次发布都该有自己专属的更新说明，还是说反复使用&amp;quot;修复了一些错误，提升了性能&amp;quot;也没问题&lt;/h2&gt;
&lt;p&gt;对于确实只是这样的发布来说，反复使用是没问题的，但要经常审视一下这句话到底有多大比例的时候是真的属实。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;怎样写发布说明&lt;/a&gt; 已经讲过，为什么这句话会暴露出这是一份从内部视角写出来的说明，而不是为读者写的；在移动端，这会造成双重伤害，因为应用商店的发布说明是少数几个能让部分用户在两次更新之间，真的看到点什么的地方之一，而一长串&amp;quot;修复了一些错误，提升了性能&amp;quot;，读起来就像这个应用根本没有在变化，这给人留下的印象，比那段时间里干脆什么更新说明都没有还要更差。&lt;/p&gt;
&lt;h2&gt;发布说明到底会不会影响人们要不要去更新这个应用&lt;/h2&gt;
&lt;p&gt;会，但是通过可见度而不是说服力，间接地起作用。大多数用户是自动更新的，从来不会在更新前去读这些说明；这些说明真正重要的对象，是那一小撮手动检查更新的用户，以及那些会翻看应用商店更新历史的评测者或媒体记者。为这一小群读者去认真写，依然是值得的，因为一份带有真实的、具体的、有日期的更新历史的商店页面，读起来就像一个正在被积极维护的应用，而一份连续一整年都写着&amp;quot;修复了一些错误，提升了性能&amp;quot;的页面，无论那段时间里实际上到底发布了多少东西，都读不出这种感觉。&lt;/p&gt;
&lt;h2&gt;那强制更新呢？这种情况下说明必须解释清楚为什么用户根本没有选择余地&lt;/h2&gt;
&lt;p&gt;把原因和截止日期放在第一行说清楚，放在其他任何内容之前，因为强制更新是唯一一种读者在开始阅读之前就已经很不爽的情况。&amp;quot;这次更新是继续同步你的数据所必需的。请在[日期]之前完成更新，以免造成中断。&amp;quot;用一句话就说清楚了该做什么、为什么要做；如果把这个原因埋在三行毫不相关的功能更新说明底下，读起来就像这个应用在故意隐瞒那个让人不舒服的部分。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;移动端的发布说明是不是应该跟同一次发布的网页版体验日志保持一致?&lt;/strong&gt;
应该覆盖同样的底层变更，但不需要逐字逐句一模一样。网页版体验日志可以承担得起完整的解释；移动端说明需要的是把同样的事实压缩成一句动词在前的话，这通常意味着这是一次重写，而不是一次复制粘贴。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;给每一种支持的语言都做移动端发布说明的本地化，值得吗?&lt;/strong&gt;
值得，甚至比网页版体验日志更值得，因为应用商店的页面，往往是部分用户在两次使用之间唯一能看到的、经过本地化的界面，而且这两个平台都支持按语言区域分别提供发布说明，除了翻译本身之外，不需要额外的工程工作量。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果没有强制要求简短的字数上限，移动端发布说明到底该写多长?&lt;/strong&gt;
还是应该短。iOS 上 4000 字符这个上限，很少是真正的限制因素；真正限制你的是那 2-3 行的预览区域，写得超出预览区域能展示的范围，只会让更少的人读到真正重要的那部分内容。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明的可见文字里，需要包含版本号吗?&lt;/strong&gt;
不需要。应用商店本来就已经在说明旁边显示了版本号。在文字里再重复一遍，只是在为读者眼前已经拥有的信息，多花一份本就宝贵的可见字符。&lt;/p&gt;
</content:encoded></item><item><title>Monorepo 的体验日志：到底该合成一份，还是按包拆开</title><link>https://changeloop.dev/blog/zh/monorepo-changelogs/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/monorepo-changelogs/</guid><description>Monorepo 可以为整个仓库维护一份体验日志，也可以每个包各一份，选错会让发布要么太吵，要么信息分散。本文讲如何判断，以及标签、版本号和提交说明怎样配合。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一个 monorepo 在同一个仓库里，容纳了好几个各自独立发布的东西，而体验日志首先必须回答一个问题：读者真正在意的，到底是这个仓库本身，还是里面某一个具体的包。大多数团队从来没有真正认真想过这个问题。他们因为只有一个仓库，就先从一份体验日志开始，随着时间推移不断往里加包，最后落得一个日志，让用 CLI 的人得翻过四十条完全不相关的后端记录，才能找到那条真正发布了自己那个修复的记录。真正决定正确形式的，从来不是仓库本身的结构，而是谁在读这份日志，以及他早已知道自己在找什么。&lt;/p&gt;
&lt;h2&gt;Monorepo 的体验日志，跟单一仓库的体验日志到底有什么不一样&lt;/h2&gt;
&lt;p&gt;单一仓库的体验日志有一个默认的读者群体：所有使用这个仓库所构建的那唯一一样东西的人。Monorepo 的读者群体则是按包划分的，同一个仓库里的不同包，经常按照完全不同的节奏发布，面向完全不同的使用者，处于完全不同的稳定程度。一个发布到注册中心的库，跟一个内部管理工具，完全可以住在同一个 monorepo 里，而对读体验日志的人来说，两者之间几乎没有任何共同点。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;仓库形态&lt;/th&gt;
&lt;th&gt;典型读者&lt;/th&gt;
&lt;th&gt;合适的体验日志&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;单一可部署应用&lt;/td&gt;
&lt;td&gt;所有使用该产品的人&lt;/td&gt;
&lt;td&gt;一份日志，覆盖整个仓库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;库的工作区（多个已发布的包）&lt;/td&gt;
&lt;td&gt;依赖某个具体包的人&lt;/td&gt;
&lt;td&gt;每个包一份日志&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;应用加内部工具&lt;/td&gt;
&lt;td&gt;两类完全不重叠的读者&lt;/td&gt;
&lt;td&gt;按读者划分，而不是按文件夹划分&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;应用加自己的 SDK&lt;/td&gt;
&lt;td&gt;产品的使用者，以及 SDK 的集成者&lt;/td&gt;
&lt;td&gt;两份日志：产品向和 SDK 向&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;是不是每一个包都需要自己单独的体验日志&lt;/h2&gt;
&lt;p&gt;只有拥有独立读者群体的包才需要。一个发布到注册中心的包，需要自己单独的日志，因为安装它的人根本没有理由去读仓库里的其他任何东西，而且像 &lt;a href=&quot;https://lerna.js.org/&quot;&gt;Lerna&lt;/a&gt; 和 Changesets 这样的 monorepo 发布工具，都会为每个包写一份 &lt;code&gt;CHANGELOG.md&lt;/code&gt;，就放在它自己的 &lt;code&gt;package.json&lt;/code&gt; 旁边。一个只有一个使用者的内部工具，这个使用者就是已经住在同一个仓库里的那个应用，它根本不需要单独的日志；把它的改动直接并进那个应用自己的记录里，远比再维护一份团队之外没人会打开的第二个文件更有用。&lt;/p&gt;
&lt;p&gt;判断标准跟判断任何一条记录到底该不该进体验日志，是同一个标准：读者会不会注意到这个变化，会不会在意，以及知道了之后能不能据此采取行动。把这个标准按包应用，而不是按文件夹应用，一个有十二个包的仓库，最后完全可能只留下两份真正需要的体验日志，剩下十个包根本不需要日志。&lt;/p&gt;
&lt;h2&gt;到底该怎么知道，是哪个包造成了哪一条体验日志记录&lt;/h2&gt;
&lt;p&gt;在写下每一条记录的那一刻，就用对应的包给它打上标签，而不是事后再去查这个提交到底改了哪些文件。一个修复共享内部库的提交，完全可能在每一个依赖它的包里，各自产生一条体验日志记录，而光靠文件路径，根本没法说清楚这些下游记录里，到底哪一条是读者真正需要看到的；能做出&amp;quot;这一点对使用包 A 的人可见、对使用包 B 的人不可见&amp;quot;这种判断的，只有人。&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;Conventional commits&lt;/a&gt; 通过在每次提交里点名具体的包，从机制上帮上了忙，但作用域本身也只能产生一份草稿。那篇文章里同样的两层规则，在这里按包套用依然成立：哪怕作用域标注得再正确，草稿在真正用那个包的读者能懂的话说出来之前，仍然需要人工过一遍。&lt;/p&gt;
&lt;h2&gt;一份共享体验日志，需要什么是单一仓库的体验日志用不上的&lt;/h2&gt;
&lt;p&gt;每一条记录最前面、描述之前，都要有一个包的标签，这样浏览日志的读者才能一遍扫过去，就跳过所有跟自己无关的内容。没有这个标签，共享日志读起来就像一份随机拼凑的信息流，一个只关心某一个包的读者，除了硬记住哪几行才算数之外，根本没有别的办法把它筛选出来，而这件事过了第一周基本没人还会坚持做。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## 2026-09-07

### [cli] 新增
- `acme push --dry-run` 会展示将要发送的内容，但实际
  上并不会真的发送出去。

### [core] 修复
- 一个返回空响应体的成功请求，不会再触发重试退避的重置了。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两条记录，两类读者，一眼就能分清楚。像 &lt;a href=&quot;https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md&quot;&gt;Changesets&lt;/a&gt; 这样的工作流，直接把这种打标签的做法内建进了发布流程本身：贡献者在自己的改动旁边，写一条带着包作用域的简短说明，工具再在发布那一刻，从这些说明里组装出按包划分的体验日志和版本号跳跃，而不是事后试图从一段已经合并在一起的提交历史里，反推出各个包之间的边界。&lt;/p&gt;
&lt;h2&gt;版本管理跟 monorepo 的体验日志到底是怎么联系在一起的&lt;/h2&gt;
&lt;p&gt;独立管理版本号的包，需要自己单独的体验日志，因为它们各自有自己独立的版本号，一份共享的体验日志根本没法表达&amp;quot;包 A 从 2.1 升到了 2.2，而包 B 还停在 1.4&amp;quot;这种情况，除非变成一个文件里塞进两份日志。&lt;a href=&quot;https://changeloop.dev/blog/zh/semantic-versioning-changelog/&quot;&gt;语义化版本控制，应该怎样配合你的体验日志&lt;/a&gt; 完整讲解了版本号本身应该怎样对应到体验日志的分类上；在 monorepo 里，这套对应关系必须按包来套用，因为某一个包里的破坏性变更，对不依赖它的兄弟包来说，根本算不上破坏性变更。&lt;/p&gt;
&lt;p&gt;一个仓库如果把一个产品作为单一可部署单元来发布，哪怕它内部由许多个内部包构建而成，也完全不会遇到这个问题：这些包始终一起发布，因此共用同一个版本号，用一份体验日志就是正确的做法。&lt;/p&gt;
&lt;h2&gt;Git 标签在 monorepo 里到底该怎么用&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://changeloop.dev/blog/zh/git-tags-releases-changelog/&quot;&gt;Git 标签、发布记录和你的体验日志该怎样对应&lt;/a&gt; 里同样的规则，按包套用之后依然成立：一个拥有自己独立版本号的包，需要自己单独的标签前缀，通常写成 &lt;code&gt;包名@1.4.0&lt;/code&gt;，而不是一个说不清到底属于哪个包的、光秃秃的 &lt;code&gt;v1.4.0&lt;/code&gt;。一个只用光秃秃版本号打标签的 monorepo，事后根本没法回答&amp;quot;&lt;code&gt;cli&lt;/code&gt; 发布 2.2 的时候，&lt;code&gt;core&lt;/code&gt; 里到底是什么内容&amp;quot;，因为磁盘上完全没有任何东西，记录下这个标签当初到底属于哪个包。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Monorepo 里的每一个包，是不是都需要单独的体验日志?&lt;/strong&gt;
只有拥有独立读者群体的包才需要，通常也就是发布到注册中心的那些包。一个只有一个内部使用者、而这个使用者已经住在同一个仓库里的包，完全可以并进那个使用者自己的日志里，而不用单独维护一份。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;是什么东西，把一条体验日志记录标注成正确的包?&lt;/strong&gt;
是写下这条记录的人，就在他写下这条记录的那一刻做出的判断，而不是对改动过的文件路径做一次自动扫描。一次共享库的改动，完全可能在每一个依赖它的包里各自产生一条不同的记录，而这些下游记录到底该各自说些什么，只有人能决定。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monorepo 是不是应该给所有东西都用同一个版本号?&lt;/strong&gt;
只有在每一个包始终跟其他包一起发布的情况下才成立。如果这些包有朝一日会被独立发布，它们就需要各自独立的版本号，而独立的版本号，要想真正有意义，就需要各自独立的体验日志。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monorepo 的体验日志工具，是不是能替代人工编辑这一步?&lt;/strong&gt;
不能。像 Changesets 这样的工具，自动化的只是在发布那一刻收集并组装各个包的说明这件事本身；说明的内容本身，用读者的语言而不是贡献者的语言写出来，跟其他任何体验日志流程一样，依然是一个人的工作。&lt;/p&gt;
</content:encoded></item><item><title>该怎样发布一条新功能公告（而不是悄无声息）</title><link>https://changeloop.dev/blog/zh/new-feature-announcement/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/new-feature-announcement/</guid><description>大多数新功能公告，最后都死在了一个没人会看第二遍的渠道里。本文说明该在哪里发布、第一句话说什么、怎样直接触达提出过请求的人，以及四种渠道如何搭配使用。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;大多数新功能公告，最后都死在了一个没人会读第二遍的渠道里：一条一晃而过就消失的推文，一封被那一周订阅者收到的另外十二封邮件压在最底下的发布日邮件，一条发在半个团队几个月前就已经静音的频道里的 Slack 消息。功能确实已经发布了。可几乎没有一个本该会用它的人真正知道这件事。要解决这个问题，与其说是要写一份更好的公告，不如说更多是要为正确的读者选对正确的渠道，直接触达那些明确提出过这个请求的人，而不是指望她们会碰巧注意到一条通用消息。&lt;/p&gt;
&lt;h2&gt;一条新功能到底应该在哪里发布&lt;/h2&gt;
&lt;p&gt;不止一个地方，因为&amp;quot;所有人都读同一个渠道&amp;quot;这种情况从来都不成立。一条体验日志或者订阅源里的记录，服务的是那种按自己节奏来查看、想要一份永久保留且带日期的记录的读者。一条应用内提醒，服务的是那种已经在用这个产品、只要知道这个功能存在，今天就会去用它的读者。一封邮件，服务的是那种目前不在产品里、但只要更新内容对了就会回来的读者。社交媒体，服务的则是覆盖到现有用户之外的触达，只是几乎没有任何精准定向可言。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;渠道&lt;/th&gt;
&lt;th&gt;最适合的对象&lt;/th&gt;
&lt;th&gt;弱点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;体验日志 / 订阅源&lt;/td&gt;
&lt;td&gt;永久性的记录；按自己节奏查看的读者&lt;/td&gt;
&lt;td&gt;被动；对从来不主动查看的人毫无用处&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;应用内提醒&lt;/td&gt;
&lt;td&gt;已经在使用、今天就会采取行动的用户&lt;/td&gt;
&lt;td&gt;完全触达不到当下没有登录的人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;邮件&lt;/td&gt;
&lt;td&gt;目前不活跃、但会为此专门回来的用户&lt;/td&gt;
&lt;td&gt;很容易被其他邮件淹没；需要一个真正有吸引力的标题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;社交媒体&lt;/td&gt;
&lt;td&gt;覆盖到现有用户之外的触达&lt;/td&gt;
&lt;td&gt;几乎没有精准定向；生命周期很短&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这四种渠道，单独拿出任何一种都不够用。&lt;a href=&quot;https://changeloop.dev/blog/zh/what-is-a-changelog/&quot;&gt;体验日志&lt;/a&gt;是唯一一个不管发布内容大小、都理应承载每一次发布的文档，因为它是其他所有渠道最终都会回头去引用的那份记录；其余三种，只是在此基础上叠加上去的放大手段，具体要不要用，取决于这个功能实际上到底有多大。&lt;/p&gt;
&lt;h2&gt;公告应该先说什么&lt;/h2&gt;
&lt;p&gt;先说结果，而不是实现机制。&amp;quot;我们给报表接口加了一层缓存&amp;quot;描述的是团队具体做了什么。&amp;quot;报表现在能在一秒之内加载出来&amp;quot;描述的则是对读者来说到底发生了什么变化，而正是这句话能拿到点击，因为它在第一句话里就回答了&amp;quot;这跟我有什么关系&amp;quot;，而不是拖到第三句话才说。实现机制该放进体验日志记录或者详情页里，不该放在标题里。&lt;/p&gt;
&lt;p&gt;具体的信息要排在形容词前面。&amp;quot;更快、更强大的报表体验&amp;quot;没有告诉读者任何她可以据此采取行动的东西；&amp;quot;报表现在能在一秒之内加载出来，还能按状态筛选&amp;quot;则准确说出了到底变了什么、值得去试试看什么。第二种写法读起来也更可信，因为一句含糊的断言，听起来就和没什么具体内容可说时的营销文案一模一样。&lt;/p&gt;
&lt;h2&gt;这和一封产品更新邮件到底有什么不同&lt;/h2&gt;
&lt;p&gt;两者有重叠，但并不完全等同。&lt;a href=&quot;https://changeloop.dev/blog/zh/product-update-email/&quot;&gt;产品更新邮件&lt;/a&gt;专门讲解了邮件这个渠道，包括发送频率、标题写法，以及什么时候用摘要邮件会比单独发一封更好。一条新功能公告，是背后那个真正的事件本身；邮件只是上面四种渠道之一，当这个功能足够大、值得为它专门发一封邮件，而不是搭顺风车放进下一份摘要邮件里的时候，才会被选中。一个小功能，配得上一条体验日志记录，也许再加一条应用内提醒。一个重大功能，则配得上这全部四种渠道，而且要在时间上互相配合好。&lt;/p&gt;
&lt;h2&gt;该怎样触达那些真正提出过这个请求的人&lt;/h2&gt;
&lt;p&gt;这是投入产出比最高的一种公告方式，可几乎所有团队都会跳过它。如果有十个客户点名要求过某个功能，那这十个人就值得在功能发布的那一刻，得到一条直接、私人化的通知，跟外面正在发出的任何更广泛的公告都没有关系。&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭环：从客户反馈到告知客户&lt;/a&gt;完整讲解了背后的机制；这里要说的重点是，这件事只有在原始请求始终和提出它的人保持关联的情况下才能真正生效，而这其实更像是一个&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;追踪问题&lt;/a&gt;，而不是一个公告问题。在 changeloop 里，当小组件反馈变成了一个 GitHub issue，而合并的 pull request 关闭了它（&lt;code&gt;fixes #142&lt;/code&gt;）时，批准这条体验日志记录就会在那个 issue 上发布一条只发一次的&amp;quot;Shipped — &lt;title&gt;&amp;quot;评论，链接回那条正在生效的记录；发送反馈的那个人也会在小组件里看到这条已上线的记录。完全不需要任何人凭记忆去专门告诉她。手工提交的 issue，以及 GitLab 或 Bitbucket 仓库，都不会收到这条评论。&lt;/p&gt;
&lt;h2&gt;记录本身该怎么写&lt;/h2&gt;
&lt;p&gt;和任何一条发布说明记录一样的自律：先从读者现在能做到的事情说起，接着补上必要的设置步骤，跳过内部理由的说明。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;如何写发布说明&lt;/a&gt;完整讲解了这套方法；一条新功能公告是其中风险最高的一种情况，因为它是最有可能被截图、被转发、被一个从来没见过这个产品体验日志的人读到的那种记录。&lt;/p&gt;
&lt;h2&gt;什么时候不该大范围发布公告&lt;/h2&gt;
&lt;p&gt;当这个功能还只是逐步向一部分账户推出、确实还是一个测试版，或者定价、访问权限的限制使得十个大范围公告的读者里有九个根本还用不上它的时候。对一个十个读者里有九个都还用不上的功能大范围发布公告，读起来就像一个诱饵，烧掉的对下一次公告的信任，比它在这一次带来的兴奋感还要多。解决办法不是保持沉默，而是控制范围：直接通知符合条件的账户，把大范围的渠道留到可用性真正追上公告内容的那一刻。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;是不是每一个新功能都值得拥有一条专属公告?&lt;/strong&gt;
每一个都值得拥有一条体验日志记录。只有那些重要到足以改变别人使用这个产品方式的功能，或者是被点名明确要求过的功能，才值得动用邮件或社交媒体这样更广泛的渠道。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;小功能最适合用哪个渠道?&lt;/strong&gt;
只用体验日志就够了，如果这个功能能在用户本来就已经身处其中的流程里被自然发现，再加一条应用内提醒。邮件和社交媒体，值得留给那些配得上主动请求关注度的功能。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;该怎样向那些明确提出过请求的人发布这条功能的公告?&lt;/strong&gt;
从请求被记录下来的那一刻起，就让它始终和提出请求的人保持关联，等到发布时，再和任何更广泛的公告分开、单独通知她。一个提出请求的人自己就能查到的共享状态标签，也能从根本上减少一开始需要发送多少条单独的通知。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;功能公告需要配一张截图吗?&lt;/strong&gt;
只要是和视觉相关的功能，就需要；一个只有文字描述、却看不到实际效果的功能，被跳过的概率，会比一个读者能看到预览效果的功能高得多。如果是 API 或者后端能力，一段简短的代码示例，起到的作用就和 UI 变更配一张截图完全一样。&lt;/p&gt;
</content:encoded></item><item><title>功能请求越堆越多的时候，到底该怎样排优先级</title><link>https://changeloop.dev/blog/zh/prioritizing-feature-requests/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/prioritizing-feature-requests/</guid><description>功能请求都已跟踪、分组、打好标签后，仍有个更难的问题：该先做哪一个。本文讲几种管用的优先级框架、RICE 是否适合功能请求，以及原始投票数掩盖了什么。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;把功能请求跟踪起来，解决的是它们该放在哪里这个问题。真正该先做哪一个，这个问题并没有因此被解决，而团队真正卡住的地方，恰恰就是这第二个问题。哪怕已经分好组、打好标签的三百条请求堆在待办列表里，依然需要一条决策规则，因为&amp;quot;做那个被要求得最多的东西&amp;quot;这条规则，只有在两条请求票数接近、而第三条又冒出一个嗓门特别大的支持者之前才管用，而这种情况几乎每周都会发生。下面这几种框架，并不是在争夺同一个问题的答案。每一种都恰好适合某一类特定的请求，而拿其中一种去套所有情况，往往才是真正的错误所在。&lt;/p&gt;
&lt;h2&gt;给功能请求排优先级，跟给路线图排优先级，到底有什么不一样&lt;/h2&gt;
&lt;p&gt;路线图上的决策，起点是战略，问的是该做什么。功能请求上的决策，起点则是已经真实存在的需求，问的是要不要针对它采取行动，而这两者足够经常地朝着相反的方向拉扯，以至于一条需求量很大的请求，完全可能依然不该被做；一条需求量很小的请求，也完全可能因为能打开一个战略性客户而依然值得去做。把每一条请求都当成路线图上的一张选票来对待，恰恰跳过了这一步检查。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;框架&lt;/th&gt;
&lt;th&gt;权衡的是什么&lt;/th&gt;
&lt;th&gt;在哪里会失灵&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;原始请求数量&lt;/td&gt;
&lt;td&gt;多少人提出过要求&lt;/td&gt;
&lt;td&gt;奖励的是好记的名字，而不是真实需求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RICE&lt;/td&gt;
&lt;td&gt;覆盖面、影响力、把握度、投入成本&lt;/td&gt;
&lt;td&gt;需要没人对一条新请求真正掌握的估算数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;按收入加权&lt;/td&gt;
&lt;td&gt;是谁提出的，按客户账户价值算&lt;/td&gt;
&lt;td&gt;会忽略那些价值暂时还不高的账户提出的请求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;公开投票&lt;/td&gt;
&lt;td&gt;一个看得见、成本很低的信号&lt;/td&gt;
&lt;td&gt;只能触达那些本来就知道该去哪里看的用户&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;RICE 到底是什么，它对功能请求真的管用吗&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.intercom.com/blog/rice-simple-prioritization-for-product-managers/&quot;&gt;RICE&lt;/a&gt; 从覆盖面、影响力、把握度和投入成本这四个维度给一个想法打分，再用前三项除以第四项，得到一个可以互相比较的数字。它最初是为团队已经相信的路线图想法设计的，真正难的部分是把各种不一样的赌注互相拿来比较。功能请求本身就自带一个覆盖面数字，也就是提出请求的人数，这比一个刚冒出来的路线图想法通常自带的覆盖面要具体得多。RICE 在面对一条请求时真正会绷紧的地方，是把握度和影响力：一个团队完全可以确信某条请求是真实存在的，却依然没有任何依据，说清楚它到底会把某个指标推动多少，因为一条已经有名字、有真实用户留下痕迹的请求，它的&amp;quot;影响力&amp;quot;跟一个屋子外面还没人见过的想法的影响力，本来就是两种性质不同的估算。&lt;/p&gt;
&lt;p&gt;把 RICE 用在那些正被认真考虑、但还没拍板的请求上。不要拿它去套每一条刚进来的请求；打分这件事本身的投入，只有在两条请求票数足够接近、真的需要一个东西来打破平局的时候，才算得上划算。&lt;/p&gt;
&lt;h2&gt;到底该按收入加权，还是该按提出请求的人加权&lt;/h2&gt;
&lt;p&gt;按提出请求的是谁加权，但不能只看收入。一个即将续约的账户、一个已经升级投诉过一次的账户、以及一个其请求正卡着某笔正在进行中的交易的账户，都带着一种单靠一个扁平的收入数字根本抓不住的紧迫性，而一条来自试用注册的请求，只要它正卡着一个很快就会变成收入的决策，依然可能非常重要。按收入加权是这几种方法里算起来最容易的一种，也正因为这一点，它才最容易被过度信任：它能正确地滤掉那些没有真实利害关系的账户带来的噪音，但同样轻易地，它也可能压低一条本能带来一个仍处在销售管道里、体量大得多的账户的请求。&lt;/p&gt;
&lt;h2&gt;投票到底扮演着什么样的真实角色&lt;/h2&gt;
&lt;p&gt;对已经存在的请求来说，投票是一个便宜、持续的信号，但用它来发现哪些请求原本就该存在，效果并不好。投票数只能触达那些已经找到这条请求、并且认为值得点一下的用户，这就意味着一个公开路线图上的投票总数，反映出来的可见度跟需求本身其实差不多多：一条排在列表靠前位置的老请求，会因为它更容易被找到这个原因，继续源源不断地积累投票，而一条同样真实、只是更新的请求，只能从零开始。&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;公开路线图&lt;/a&gt; 一文主张把投票从路线图上完全拿掉。把投票当成一个需要分组、并按新鲜程度加权的信号来对待，而不是一份按顺序照单全收的排行榜。
&lt;a href=&quot;https://changeloop.dev/blog/zh/feedback-signal-quality/&quot;&gt;支持工单对功能请求&lt;/a&gt; 讲的是投票数里的另一个盲点：如果遇
到某个真实缺口的用户根本找不到看板，这个缺口可能几乎产生不了投票，尽管它在支持渠道里其实
吵得很响。&lt;/p&gt;
&lt;h2&gt;声音最大的客户什么时候会赢，这算不算一个问题&lt;/h2&gt;
&lt;p&gt;有时候会赢，而这只有在没人注意到的时候，才真正算得上一个问题。一个经常升级投诉、写详细工单、或者跟团队里某个人有直接联系渠道的客户，他的请求被审视的速度，会比一个同样正当、但更安静的客户快得多，而一个从来不去核查这一点的优先级排序流程，只会系统性地偏向最坚持不懈的那个人，而不是理由最充分的那个人。声音大的客户本身并不是需要修正的问题；他们的请求往往真的很重要。真正该修正的是一种习惯：定期按来源把待办列表过一遍，检查是不是同一小撮账户解释了最近发布内容的大部分，再问问自己，这跟真实需求真正所在的位置是否吻合。&lt;/p&gt;
&lt;h2&gt;一个优先级决策，到底该怎样变成一句回复&lt;/h2&gt;
&lt;p&gt;这里做出的每一个决定，都会同时产生赢家和输家，而两者都应该得到一个说清楚真实理由的回复，而不是一次没有任何解释的状态变更。&lt;a href=&quot;https://changeloop.dev/blog/zh/declining-feature-requests/&quot;&gt;该怎样拒绝一个功能请求&lt;/a&gt; 讲解了该对一条落选的请求说些什么，才能让它听起来不像一句套话式的拒绝，同时把关系维护得完好无损。让这一切从一开始就变得可能的分组和打标签工作，&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-tracking/&quot;&gt;功能请求追踪&lt;/a&gt; 里有完整讲解；排优先级这件事，只对那些已经被记录下来、并且分组分得足够好、可以拿来互相比较的请求才真正管用。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;给功能请求排优先级，最好的框架到底是哪一个?&lt;/strong&gt;
没有哪一个能单独撑起全部工作。用原始数字去找出声音最大的信号，用 RICE 去比较一份认真挑出来的候选短名单，再用一次收入或账户层面的核查，去捕捉那些来自战略客户的安静需求，实际上比一个声音更大、却没那么重要的群体分量更重的情况。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;功能请求是不是应该跟路线图想法用同一套方式排优先级?&lt;/strong&gt;
不应该。路线图想法的起点是战略；功能请求的起点则是已经真实存在的需求。把两者放在一起打分，会让一个论证扎实、但现有需求不多的战略性赌注，持续输给一条仅仅因为提出的人数更多而胜出的请求。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;公开路线图上的投票，是不是能准确反映真实需求?&lt;/strong&gt;
只在那些已经找到这条请求的人当中才成立。更老、更显眼的请求，会更快地积累投票，跟一条更新的请求背后到底藏着多少真实需求完全无关，所以把投票总数当成一个需要分组、并按新鲜程度加权的信号来对待，而不是一份按顺序照单全收的排行榜。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;功能请求的优先级，到底应该多久重新评估一次?&lt;/strong&gt;
按一个固定的周期来做，而不是只在有人升级投诉的时候才做。一次每月或每季度进行的复盘，重新给请求分组、重新核查权重，能够捕捉到诸如&amp;quot;某一小撮账户主导了发布内容&amp;quot;这类漂移，而一个纯粹被动反应的流程，永远没法自己把这种情况暴露出来。&lt;/p&gt;
</content:encoded></item><item><title>企业发布说明：一个账户到底会变成什么样子</title><link>https://changeloop.dev/blog/zh/private-release-notes-enterprise/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/private-release-notes-enterprise/</guid><description>企业发布说明必须针对运行私有构建版本的具体客户实例校准，不能照搬公开通用版本。校准出错，要么用客户还没有的改动让他们困惑，要么泄露内部路线图。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一款公开的 SaaS 产品会把同样的发布说明发给所有人，因为所有人都在同一个版本上。而一个固定在
某个版本上、拥有专属实例、或者只使用了产品里带功能开关子集的企业客户，会打破这个假设：描述
她那边发生了什么变化的发布说明，跟你公开博客上的那份并不一样，可即便如此还是把公开版发给她，
要么会用她还没有的变化把她弄糊涂，要么更糟糕的是，会告诉她一个另一位企业客户的客户经理明确
要求你替他们那边再暂缓一个月的功能。&lt;a href=&quot;https://changeloop.dev/blog/zh/release-notes-best-practices/&quot;&gt;发布说明最佳实践&lt;/a&gt;
讲的是通用的写作技艺；这篇文章讲的是怎样为那个只有当你的客户不再全都用着同一个构建版本时，
才会浮现出来的校准问题，写出真正合格的企业发布说明。&lt;/p&gt;
&lt;h2&gt;为什么企业客户不能干脆去读公开的体验日志&lt;/h2&gt;
&lt;p&gt;因为它描述的是一个她可能还没有运行的版本，一些她可能还没有权限使用的功能，以及一份跟她自
己的节奏对不上的时间表。一个固定在季度发布周期上的客户，读到一个上周才发布到公开层级的功
能，光凭公开的体验日志根本没办法知道，这个功能会在下周还是下个季度才到她手上。公开体验日
志回答的是&amp;quot;产品里发生了什么变化&amp;quot;；企业客户真正想问的是&amp;quot;我正在运行的这个版本里发生了什么变
化，我什么时候能拿到剩下的部分&amp;quot;，而这个问题，公开体验日志从来就不是为了回答它而写的。&lt;/p&gt;
&lt;h2&gt;一份私有发布说明需要什么，是公开版本不需要的&lt;/h2&gt;
&lt;p&gt;一个客户能真正拿来核对的版本或环境标识符，以及一份关于哪些内容还没到达她那里的明确声明。
&amp;quot;这个版本包含了我们公开 4.3 版本里的批量导出改进，但不包含将在你下一次计划中的更新里到达
的新权限模型&amp;quot;，这句话能准确告诉一位企业管理员，她的实例相对于整个产品到底处在什么位置。公
开发布说明永远不需要这种框架，因为可以拿来做相对比较的实例只有一个；而私有版本没有它就毫
无意义。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;公开发布说明&lt;/th&gt;
&lt;th&gt;私有（企业）发布说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;一个版本，一个受众&lt;/td&gt;
&lt;td&gt;多个版本，被细分的受众&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;假设读者拥有每一个被描述的功能&lt;/td&gt;
&lt;td&gt;必须声明读者拥有和不拥有什么&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;跟公开发布同步&lt;/td&gt;
&lt;td&gt;跟客户自己的更新窗口同步&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可以立即完全公开&lt;/td&gt;
&lt;td&gt;可能需要保留其他客户还没有的项目&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;干脆推迟把公开发布说明发给企业客户，而不是另外单独写一份，这样做有时候可以接受吗&lt;/h2&gt;
&lt;p&gt;只有在她那时候的版本真的跟公开版本一致的情况下才可以，而一旦你有超过几个节奏不一致的企业
账户，这种情况其实比听起来要罕见得多。推迟发送公开说明，对一个落后一个版本、马上就要跟上
来的客户来说，可以当作一种权宜之计；但一旦两个企业客户各自处在不同的版本上，这套做法就会
崩溃，因为这时候已经不存在一份可以推迟的&amp;quot;说明&amp;quot;了，只剩下一张记录每个人拥有什么的矩阵。到
了这个地步，按账户校准说明，哪怕只是对同一批底层条目做过滤后的视图，也就不再是可选项了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;发给一个还没有这个功能的企业账户的
公开说明：
&amp;quot;New: Bulk export now supports custom column ordering.&amp;quot;
（令人困惑：管理员去试了，发现根本没有。）

为同一个账户校准过的企业说明：
&amp;quot;Available in your next update (scheduled for 2026-10-15):
bulk export with custom column ordering. Not yet available
on your current version (3.8).&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;客户组织内部真正读这份说明的是谁，这会改变写法吗&lt;/h2&gt;
&lt;p&gt;通常是一位 IT 管理员或者客户成功联系人，而不是终端用户，而这会改变什么才算有用。终端用户
想知道自己屏幕上有什么看起来不一样了；企业管理员想知道权限、数据处理、SSO 配置，或者任何
会影响她如何为自己的用户管理部署的东西发生了什么变化，因为要回答内部问题的人正是她。一份
读起来像消费者体验日志的私有发布说明，满是崭新闪亮的按钮，却完全没有运营层面的细节，只会
逼着管理员自己去挖掘她真正需要的信息。&lt;/p&gt;
&lt;h2&gt;这跟一份已经列出同一个功能的公开路线图或公开体验日志之间是怎样相互作用的&lt;/h2&gt;
&lt;p&gt;要小心处理，因为一个同时读两者的客户会注意到任何不一致。如果你的公开体验日志已经宣布了一
个某个特定企业账户还没有的功能，她的私有发布说明就必须承认这个差距，而不是假装那条公开条
目根本不存在；一个看过公开公告、又收到一份对此只字不提的私有说明的管理员，要么会以为你把
她忘了，要么会以为出了什么故障。&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;公开路线图&lt;/a&gt;讲的是如何让一份路线
图对已发布内容与计划中内容保持诚实；这份诚实在发布说明里的企业版本，就是直接说清楚公开的
内容和属于她的内容之间的差距。&lt;/p&gt;
&lt;h2&gt;只有一两个企业客户的小公司，需要这么多结构吗&lt;/h2&gt;
&lt;p&gt;不需要完全细分的系统，但那份核心纪律，清楚说明客户处在哪个版本、她拥有什么、不拥有什么，
在任何规模下都很重要，只要你哪怕有一个客户不在你最新的构建版本上。这能防止的那种失败模
式，一个搞不清楚某条公开公告是否适用于自己的管理员，无论你有两个企业账户还是两百个，都会
让你付出一张支持工单和一次信任受损的代价。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;私有发布说明是否应该提及别的客户已经拥有、但这个客户还没有的功能?&lt;/strong&gt;
只有在跟她自己的时间表相关的时候才可以，而且要表述成&amp;quot;将在你下一次更新中到达&amp;quot;，而不是跟其
他客户做比较。点名说出某个特定的其他客户拥有什么，越过了一条不该由你来公开的界线；而点名
说出什么将专门为这个客户到来，正是她需要的信息。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同一批底层体验日志条目，能不能同时支撑公开和私有两种发布说明?&lt;/strong&gt;
可以，而且这通常是更容易维护的做法：给条目打上标签，标明它们适用于哪些版本或哪些层级，然
后在发布时按受众过滤，而不是去写两份完全独立、注定会渐渐脱节的文档。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果一个企业客户明确要求出现在公开发布说明上，而不是走私有信息流，该怎么办?&lt;/strong&gt;
尊重这个要求，但要确认她明白公开说明是以公开版本为前提的，而且如果她的版本跟描述有出入，
就自己用书面形式标注出这个差距。这份书面确认，正是日后万一她根据一份实际上并不适用于她那
个构建版本的公开说明采取了行动时，能保护你的东西。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;企业客户应该提前多久被告知，她将在下一次发布中获得访问权限的功能?&lt;/strong&gt;
一旦日期确定下来就应该立刻通知，而不是等到发布的那一刻，因为企业管理员往往需要围绕即将到
来的功能，规划她自己内部的沟通或培训，而当天才发出的通知不会给她留下任何这样做的余地。&lt;/p&gt;
</content:encoded></item><item><title>语义化版本控制，应该怎样配合你的体验日志</title><link>https://changeloop.dev/blog/zh/semantic-versioning-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/semantic-versioning-changelog/</guid><description>语义化版本控制让调用方在读任何内容之前，就知道这次发布可能带来多大影响。本文说明每个数字承诺了什么，以及一条记录应该交代什么。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;语义化版本控制会在调用方还没有读过任何一条体验日志记录之前，就先告诉她一次发布到底能带来多大的伤害。从 &lt;code&gt;2.4.1&lt;/code&gt; 升到 &lt;code&gt;2.5.0&lt;/code&gt;，说的是：新增了能力，什么都不会坏。从 &lt;code&gt;2.5.0&lt;/code&gt; 升到 &lt;code&gt;3.0.0&lt;/code&gt;，说的则是：更新之前，先把这条记录读一遍。体验日志和版本号本该用两种不同的形式，去主张同一件事情，而两者之间的大多数摩擦，恰恰就出现在它们说法不一致的那些时刻，而这种情况发生的频率，往往比这份规范本身暗示的要高得多。&lt;/p&gt;
&lt;h2&gt;版本里的每一个数字，到底承诺了什么&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://semver.org/&quot;&gt;语义化版本控制&lt;/a&gt;定义了三个数字，&lt;code&gt;MAJOR.MINOR.PATCH&lt;/code&gt;，每一个都配有一条严格的规则，说明到底是什么触发了它。MAJOR 的跃升，意味着一次不兼容的变更：一个正确、且已经存在的集成有可能会注意到，也因此不得不为之做出改变的东西。MINOR 的跃升，意味着新增了向后兼容的功能：已有的东西什么都不会坏，只是多了一些新的可用能力。PATCH 的跃升，意味着一次向后兼容的修复：行为变得更贴近文档所描述的样子，而任何刻意依赖旧行为的人，都不应该注意到任何变化。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;跃升&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;记录应该读起来像&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;MAJOR (&lt;code&gt;1.x.x&lt;/code&gt; -&amp;gt; &lt;code&gt;2.0.0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;一次不兼容的变更&lt;/td&gt;
&lt;td&gt;&amp;quot;更新之前需要采取行动&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MINOR (&lt;code&gt;1.2.x&lt;/code&gt; -&amp;gt; &lt;code&gt;1.3.0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;新增的、兼容的能力&lt;/td&gt;
&lt;td&gt;&amp;quot;从现在起就能用了，别的什么都没变&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PATCH (&lt;code&gt;1.2.3&lt;/code&gt; -&amp;gt; &lt;code&gt;1.2.4&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;一次兼容的修复&lt;/td&gt;
&lt;td&gt;&amp;quot;现在的行为终于和文档描述的一致了&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这张表反过来读，也同样是一个检验：如果一条记录读起来和它所在的那一行对不上，那要么是版本号本身标错了，要么是这条记录把实际发生的事情说得太轻或者太重了。&lt;/p&gt;
&lt;h2&gt;就版本管理而言，什么才算是不兼容&lt;/h2&gt;
&lt;p&gt;判断的标准，和判断某个东西到底该不该收进 API 体验日志里的标准是一样的：一个针对旧行为而写、且从那以后一直没有被改动过的正确调用方，会不会因为这次变更而表现出不一样的行为。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是不兼容的变更，又该如何发布它&lt;/a&gt;完整讲解了这个判断过程，也包括那些看起来不兼容、实际上并不是的情况，以及那些看起来很小、实际上并不小的情况。就版本管理而言，简单来说：只要答案是肯定的，那这次跃升就是 MAJOR，跟这次变更在内部实际改动了多少代码完全没有关系。版本号追踪的，是对调用方造成的后果，而不是团队付出的努力。&lt;/p&gt;
&lt;h2&gt;一条体验日志记录，应该怎样和一次版本跃升相对应&lt;/h2&gt;
&lt;p&gt;一条记录，对应一个跃升分类，而且要从一开始就明确说出来。表格里的这个模式会一直延续下去：一条不兼容的记录，放在引入它的那个版本下面，先以警告的方式写出来，再补上说明。一条新增功能的记录，放在它所属的 MINOR 版本下面，以&amp;quot;现在可用了&amp;quot;的方式写出来。一条修复的记录，放在它所属的 PATCH 版本下面，以&amp;quot;已经纠正&amp;quot;的方式写出来。把不同分类混在同一条记录里，比如把一次不兼容的变更硬塞进一段和它毫不相关的修复说明里，正是读者会因此错过那唯一真正重要的信息的原因。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## 3.0.0 (2026-09-07)

### Changed
- **BREAKING:** `GET /reports` 现在会把金额以最小货币单位（分）的整数
  形式返回，而不再是浮点数。请更新所有直接读取`amount` 字段的代码。

## 2.9.0 (2026-09-01)

### Added
- 报表现在可以按 `status` 进行筛选了。

## 2.8.4 (2026-08-28)

### Fixed
- `GET /reports?status=` 在遇到无法识别的状态时，原本会返回一个空页面
  ，而不是 400 错误，现在已经修正。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从上往下读，版本号和板块标签其实把同一件事情说了两遍，而这正是它们存在的意义：一个只是快速扫一眼标题的读者，在打开任何一行内容之前，就已经能对风险大小做出正确的判断了。&lt;/p&gt;
&lt;h2&gt;破坏性变更这条规则，在 1.0.0 之前也一样适用吗&lt;/h2&gt;
&lt;p&gt;不一样，而大多数关于&amp;quot;这到底算不算破坏性变更&amp;quot;的困惑，恰恰就出在这里。语义化版本控制明确说明，
主版本号为零，也就是 &lt;code&gt;0.y.z&lt;/code&gt;，代表的是初始开发阶段：任何东西都可能在任何时候发生变化，公共
API 也不应该被视为稳定的。从 &lt;code&gt;0.4.0&lt;/code&gt; 升到 &lt;code&gt;0.5.0&lt;/code&gt; 完全可以带着一个破坏性变更，而不违反规范，
因为主版本号那份保证，要到项目真正发布 &lt;code&gt;1.0.0&lt;/code&gt; 之后才开始生效。一条体验日志记录依然欠读者同
样的诚实，说清楚到底破坏了什么；唯一变化的是，在 1.0.0 到来之前，版本号本身还不是那个可以依
赖的信号。&lt;/p&gt;
&lt;h2&gt;如果你的产品根本不发布离散的版本呢&lt;/h2&gt;
&lt;p&gt;大多数 SaaS 产品都是持续部署的，也从来不会向调用方展示任何版本号，但这并不会消除对这套自律的需要，消失的只是那个本该承载它的数字而已。这时候，一条体验日志记录就必须独自完成全部的工作：清楚地说明这次变更到底是不兼容的、新增的、还是一次修复，用的还是语义化版本控制所使用的那同样三个词，哪怕根本没有一个版本字段可以把它们挂上去。有些团队会维护一个纯粹内部使用的版本号，唯一的目的就是把体验日志的记录锚定到某个可以被引用的东西上，却从来不会把这个版本号直接展示给调用方看。&lt;/p&gt;
&lt;h2&gt;这具体又是怎么应用到 API 体验日志上的&lt;/h2&gt;
&lt;p&gt;比起几乎其他任何场景都要更加严格，因为 API 的调用方是代码，不是那种可以对着一次意外的变更耸耸肩就翻篇的人。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志：该公开什么内容，又是谁在读它&lt;/a&gt;完整讲解了这份文档的完整形态；而这里的版本管理自律，正是让它的 breaking 和 additive 两个板块保持诚实的东西。一个同时提供多个版本的 API，比如在一段迁移窗口期里同时并行提供 &lt;code&gt;v1&lt;/code&gt; 和 &lt;code&gt;v2&lt;/code&gt;，实际上是在整个接口的规模上应用语义化版本控制，而不只是针对某一个软件包，而且同样这三个词组成的词汇表，仍然适用于每一条记录。&lt;/p&gt;
&lt;h2&gt;Keep a Changelog 对版本管理是怎么说的&lt;/h2&gt;
&lt;p&gt;它直接以名字和语义化版本控制关联在一起，并推荐了和本文所使用的完全相同的一套分类词汇：Added、Changed、Deprecated、Removed、Fixed、Security。&lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog，实践版&lt;/a&gt;讲解了该如何采用这份规范，也包括团队通常会在哪些地方偏离它。这种重合并不是巧合：这两份规范其实是在从相反的两端，尝试解决同一个问题，一份负责把版本号标准化，另一份则负责把解释版本号的那条记录标准化。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;是不是每一条体验日志记录都需要一个版本号?&lt;/strong&gt;
如果产品本身会发布版本，那就需要，因为这个数字能让读者不用先读记录本身，就能直接跳到&amp;quot;这件事到底对我影响有多大&amp;quot;这个问题上。如果产品是持续部署、根本没有版本字段的，那这条记录的措辞本身，就必须独自承担起传达这个信号的责任。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MAJOR 跃升和一条不兼容变更的记录之间有什么区别?&lt;/strong&gt;
它们本该用两种方式描述同一个事件。版本号是机器可读的信号（调用方的工具链可以对它做出反应）；体验日志记录则是人类可读的解释，说明到底具体发生了什么变化。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PATCH 发布有可能是不兼容的吗?&lt;/strong&gt;
按定义来说，不应该是。如果还是发布出去了，不要修改已发布的版本，也不要重新打标签：&lt;a href=&quot;https://semver.org/#what-do-i-do-if-i-accidentally-release-a-backward-incompatible-change-as-a-minor-version&quot;&gt;SemVer FAQ&lt;/a&gt; 的建议是发布一个恢复兼容性的新版本，如果这个破坏性变更要保留，就发布一个新的 MAJOR 版本，并在文档里注明出问题的那个版本，让用户知道要跳过它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;纯粹内部的变更需要版本跃升吗?&lt;/strong&gt;
不需要。语义化版本控制追踪的是公开接口。一次对调用方没有任何可观察影响的重构，即便在内部确实是一项相当可观的工程工作，也既不需要版本跃升，也不需要一条体验日志记录。&lt;/p&gt;
</content:encoded></item><item><title>API 的 Sunset 响应头：什么时候该发送它</title><link>https://changeloop.dev/blog/zh/sunsetting-api-version/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/sunsetting-api-version/</guid><description>API 的 Sunset 响应头告诉客户端一个版本何时彻底停止应答，这和只说将来会停用的通知是两回事。本文讲 RFC 8594 的规定、这个响应头能否信任，以及提前限时停机。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;Sunset&lt;/code&gt; 是一个单独的响应头，定义在 &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8594&quot;&gt;RFC 8594&lt;/a&gt; 里，它告诉
调用方一个资源什么时候会停止应答。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;API 停用&lt;/a&gt;讲的是宣布、提醒、限时停
机到服务下线这整条时间线，以及沿途需要发出的各种通知；这篇文章讲的是那条时间线里唯一一个机器
可读的信号，它到底说了什么，以及 RFC 本身明确说不应该发送它的那一种情况。&lt;/p&gt;
&lt;h2&gt;Sunset 响应头到底说了什么，又没说什么&lt;/h2&gt;
&lt;p&gt;它携带一个单独的 HTTP 日期，也就是这个资源预计会停止应答的那个时间点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sunset: Sat, 31 Dec 2028 23:59:59 GMT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RFC 把它称为一个提示，而不是一个保证：它并不承诺这个资源会一直正常工作到那个时间戳为止，也完
全没有说清楚之后失败会是什么样子。调用方可能会收到一个 4xx、一次重定向，或者干脆什么响应都没
有；这个响应头并不区分这些情况。一个已经过去的时间戳，意思是&amp;quot;现在，或者随时都可能&amp;quot;，而不是这
个值本身出了错。这一切都不是协议强制要求的。一个从来不读这个响应头的客户端，行为跟以前完全一
样，最终发现资源已经消失的方式，也和它本来会发现的方式一模一样。&lt;/p&gt;
&lt;h2&gt;到底应该在什么时候发送它&lt;/h2&gt;
&lt;p&gt;只有当这个资源真的即将停止应答的时候才发送，而不是它只是不再是推荐选项的时候。RFC 明确说明停
用分成两个阶段，而 &lt;code&gt;Sunset&lt;/code&gt; 响应头只属于第二个阶段：在第一个阶段里，也就是宣布某个版本不再被
推荐的阶段，这个 API 依然完全正常运作，这个响应头在这个阶段并不适用。它只适用于版本真的已经被
安排好、即将停止应答的那个阶段。&lt;/p&gt;
&lt;p&gt;这正好对应到停用的时间线上：&lt;code&gt;Deprecation&lt;/code&gt; 响应头从第一天，也就是宣布那一步开始发出；&lt;code&gt;Sunset&lt;/code&gt;
描述的是旧行为真正停止的那个日期，也就是&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;那条四步时间线&lt;/a&gt;里所说的服
务下线。在第一天就发送 &lt;code&gt;Sunset&lt;/code&gt; 并没有错，因为那时日期已经确定了，但如果没有先宣布停用就发送
它，或者把它设置给一个你其实还没有真正决定要下线的版本，就是在告诉调用方一件你自己都还没决定
的事情。&lt;/p&gt;
&lt;h2&gt;它会和缓存互相影响吗&lt;/h2&gt;
&lt;p&gt;不会，RFC 里直接这么说：&lt;code&gt;Sunset&lt;/code&gt; 和 HTTP 缓存解决的是两个互不相关的问题，应该把它们理解成互补
关系，而不是有重叠。缓存响应头说的是一份缓存副本什么时候可以安全地被重复使用；&lt;code&gt;Sunset&lt;/code&gt; 完全不
涉及资源当前的状态，它说的只是这个资源本身将会不再存在。一个响应完全可以一直保持可缓存，直到
它真正下线的那一刻。不要用其中一个去近似替代另一个，也不要以为一个很长的 &lt;code&gt;max-age&lt;/code&gt; 就能抵消掉
一个临近的下线日期，反过来也一样。&lt;/p&gt;
&lt;h2&gt;一个响应头能不能同时下线多个端点&lt;/h2&gt;
&lt;p&gt;这个响应头本身只适用于返回它的那个资源，但 RFC 允许一个服务把作用范围写得更宽：一个 API 主资
源上的 &lt;code&gt;Sunset&lt;/code&gt; 日期，可以被定义成代表整个 API 都将下线，而不只是那一个 URL。问题在于，这种做
法只对已经知道你这条作用范围规则的调用方有效。一个只按字面读取响应头的调用方，看到的只是它请
求的那一个资源被下线了，仅此而已，所以更宽的作用范围必须写在调用方能找到的地方，而不能只是心
照不宣。&lt;/p&gt;
&lt;h2&gt;这个响应头应该搭配什么一起发出&lt;/h2&gt;
&lt;p&gt;一个指向服务下线说明页面的链接。RFC 8594 专门为此注册了自己的 &lt;code&gt;sunset&lt;/code&gt; 链接关系：指向一个专门
描述下线政策、即将到来的日期，或者迁移方法的资源，而不只是响应头里那个光秃秃的时间戳。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 200 OK
Sunset: Sat, 31 Dec 2028 23:59:59 GMT
Link: &amp;lt;https://example.com/docs/sunset-policy&amp;gt;; rel=&amp;quot;sunset&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把这个链接指向你自己的&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;或者一个专门的迁移页面，能把一个几乎
没有任何客户端代码会去检查的响应头，变成一个真的去查找的人能立刻找到的东西。把它和&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/#which-headers-should-a-deprecated-endpoint-send&quot;&gt;停用响应头&lt;/a&gt;
里的 &lt;code&gt;successor-version&lt;/code&gt; 关系结合起来，调用方仅凭这一个响应，就能同时知道该去哪里，以及是什么
取代了这个版本。&lt;/p&gt;
&lt;h2&gt;完整走一遍是什么样子&lt;/h2&gt;
&lt;p&gt;假设 &lt;code&gt;v1&lt;/code&gt; 将在 2027 年 3 月 1 日下线。按照&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;停用响应头&lt;/a&gt;的做法，第一
天的停用公告会给每一个 &lt;code&gt;v1&lt;/code&gt; 响应加上 &lt;code&gt;Deprecation&lt;/code&gt; 和 &lt;code&gt;Link: rel=&amp;quot;successor-version&amp;quot;&lt;/code&gt;，但会先
按住 &lt;code&gt;Sunset&lt;/code&gt;，直到那个下线日期真正确定下来，而不是一个占位值。一旦确定，每一个 &lt;code&gt;v1&lt;/code&gt; 响应就会
带上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 200 OK
Deprecation: @1756425600
Sunset: Mon, 01 Mar 2027 00:00:00 GMT
Link: &amp;lt;https://api.example.com/v2/reports&amp;gt;; rel=&amp;quot;successor-version&amp;quot;
Link: &amp;lt;https://example.com/docs/sunset-policy&amp;gt;; rel=&amp;quot;sunset&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调用方的网关或监控系统可以针对这两个响应头分别独立地发出警报：&lt;code&gt;Deprecation&lt;/code&gt; 说明存在一个更新
的版本，&lt;code&gt;Sunset&lt;/code&gt; 说明这个版本已经有了一个倒计时。在 3 月 1 日之前，这两个响应头都不需要发生任
何变化；真正发生变化的是响应本身，在那一天，以及在那之前安排好的任何限时停机窗口期间。&lt;/p&gt;
&lt;h2&gt;限时停机会改变这个响应头说的内容吗&lt;/h2&gt;
&lt;p&gt;为了一次计划内的限时停机，响应头本身的值并不需要跟着变动：下线日期还是那个下线日期，不管这个
资源在那之前是不是间歇性地出错。真正变化的是响应本身，而不是响应头。像&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;API 停用&lt;/a&gt;
里说的那样，在公布的日期前几周安排几段短暂的 &lt;code&gt;410 Gone&lt;/code&gt; 窗口，能让调用方第一次遇到这个失败，
变成一次预演，而不是等到响应头上那个日期真正到来的那一天才第一次遇到真正的情况。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;真的有 HTTP 客户端或工具会去读 Sunset 响应头吗?&lt;/strong&gt;
在客户端这一侧很少见。它的价值主要是给那些运营着你和调用方之间基础设施的人准备的：一个 API 网关，或者一个你配置好去监视这个响应头的监控工具，能在调用方的代码注意到之前很久，就提醒你自己的团队，或者对方的团队。把它当成一个你需要自己搭建工具去围绕它构建的信号，而不要假设对方已经有现成的东西在读它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Sunset&lt;/code&gt; 和 &lt;code&gt;Cache-Control: max-age&lt;/code&gt; 是一回事吗?&lt;/strong&gt;
不是。&lt;code&gt;max-age&lt;/code&gt; 说的是一份缓存副本能保持有效多久；&lt;code&gt;Sunset&lt;/code&gt; 说的是这个资源到底什么时候会彻底不存在。一个响应完全可以同时带着一个很短的 &lt;code&gt;max-age&lt;/code&gt;，和一个还有好几年才会到达的 &lt;code&gt;Sunset&lt;/code&gt; 日期，反过来也一样，这两个响应头互相都不会限制对方。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;只有一个字段要下线，而不是整个端点，能发送 Sunset 吗?&lt;/strong&gt;
不能，这个响应头的作用范围是资源本身，也就是那个 URL，而不是响应正文里的某个字段。对于一个字段、一个参数或一个枚举值即将消失，而端点本身继续保留的情况，应该改用 &lt;code&gt;Deprecation&lt;/code&gt; 响应头和一条体验日志条目；&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;API 停用&lt;/a&gt;讲的正是如何宣布这一类变更。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果下线日期需要推迟怎么办?&lt;/strong&gt;
更新响应头的值，并且在最初宣布它的那条体验日志条目里说明原因；悄无声息地改动一个已经公布的日期，正是调用方会觉得你所有的日期都不可信的原因。RFC 把这个值定义成一个提示，正是因为日期有时候确实会变动，但一个没有解释的改动日期，也会让下一次的日期同样失去可信度。&lt;/p&gt;
</content:encoded></item><item><title>体验日志到底是什么，里面又该放些什么内容</title><link>https://changeloop.dev/blog/zh/what-is-a-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/what-is-a-changelog/</guid><description>体验日志是对产品变化所做的带日期的记录，写给受这些变化影响的人看。本文说明它是什么、不是什么，以及好的体验日志放在哪里。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;体验日志是对产品发生了哪些变化所做的、带有日期的记录，它是写给真正受这个变化影响的那些人看的，而不是写给把它发布出去的那个团队自己看的。每一条记录都会指明一个具体的变化，说明它是从什么时候开始生效的，也说明读者应该对此做些什么，而对大多数记录来说，读者其实什么都不用做。正是这最后一点，把体验日志和提交日志区分开来：提交日志是写给写代码的人看的记录，体验日志则是写给使用它的人看的记录。&lt;/p&gt;
&lt;h2&gt;体验日志到底是什么，更准确地说&lt;/h2&gt;
&lt;p&gt;一份按日期排列的记录列表，最新的排在最前面，每一条都用读者能够自行核实的说法，描述一个单一的变化。它说的不是团队做了什么，而是现在有什么地方不一样了。&amp;quot;重构了计费服务&amp;quot;是一条提交信息。&amp;quot;发票现在会把税额单独列成一行&amp;quot;才是一条体验日志记录，因为它告诉读者一件她可以在自己账户里亲自核实的事情。&lt;/p&gt;
&lt;p&gt;这种格式很古老，也是被有意设计得很简单的：每次发布或每一天配一个标题，下面跟一份简短的列表，有时候还会带一个分类标签。&lt;a href=&quot;https://keepachangelog.com/en/1.1.0/&quot;&gt;Keep a Changelog&lt;/a&gt;是这种形式里被引用得最多的规范，它之所以存在，是因为大多数跳过规范的项目最终都会转而直接把提交历史一股脑倒出来，而这回答的其实是一个和读者本来想问的完全不同的问题。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文档&lt;/th&gt;
&lt;th&gt;写给谁看&lt;/th&gt;
&lt;th&gt;回答的问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;体验日志&lt;/td&gt;
&lt;td&gt;任何使用这个产品的人&lt;/td&gt;
&lt;td&gt;发生了什么变化，又是什么时候?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;提交日志&lt;/td&gt;
&lt;td&gt;写这段代码的团队&lt;/td&gt;
&lt;td&gt;做了什么，又是按什么顺序做的?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发布说明&lt;/td&gt;
&lt;td&gt;正在决定要不要更新的用户&lt;/td&gt;
&lt;td&gt;我现在能做什么以前做不到的事?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;补丁说明&lt;/td&gt;
&lt;td&gt;某个具体修复的玩家或用户&lt;/td&gt;
&lt;td&gt;这次发布到底修复了什么?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;路线图&lt;/td&gt;
&lt;td&gt;想知道接下来会发生什么的任何人&lt;/td&gt;
&lt;td&gt;计划了什么，又进行到哪一步了?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这五种文档在实际使用中会互相重叠，但它们并不是同一份文档，区别就在于读的那一刻，究竟是谁把它拿在手里。体验日志是那种被设计成方便被搜索、方便日后再次被链接引用的文档，正因如此，它的每一条记录都比其他文档更需要稳定的日期和稳定的网址。&lt;/p&gt;
&lt;h2&gt;一条体验日志记录里到底应该放些什么&lt;/h2&gt;
&lt;p&gt;按这个顺序，四样东西：发生了什么变化，用用户或调用方会注意到的说法表达出来；这个变化是什么时候生效的；它属于哪个分类（added、fixed、changed、removed是最常见的四种）；以及在重要的时候，读者应该为此做些什么。指向更多细节的链接是受欢迎的。一整段内部理由的说明则不受欢迎，因为读者问的从来不是为什么，而是发生了什么。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## 2026-09-07

### Added
- 发票现在会以客户账户所用的货币，把税额单独列成一行。

### Fixed
- 把报表导出为 CSV 时，如果报表超过 10,000 行，
  现在不会再丢失最后一行了。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种形式可以从一次只有两行的更新，一路扩展到一次发布里上百条记录，而完全不需要改变自身的结构，这才是判断一种格式到底管不管用的真正标准：在最忙的那一周和最清闲的那一周，读起来是不是一样。&lt;/p&gt;
&lt;h2&gt;谁来写体验日志，又是在什么时候写&lt;/h2&gt;
&lt;p&gt;是真正做出这个变化的人，在它被发布的那一刻就动手写，而不是让一位技术编辑一周之后再从工单里把它重新拼凑出来。真正碰过代码的人，才知道对用户来说到底发生了什么变化；事后才写出来的总结，往往描述的是那张工单本身，而不是最终实际发布出去的东西，而这通常会比真实的范围更宽，或者更窄。有些团队会在一条记录公开之前加入一道审阅流程，主要是为了拦住不小心混进来的内部用语，而这道审阅必须足够快，快到那条记录还能在当天就发布出去。&lt;/p&gt;
&lt;h2&gt;体验日志到底应该放在哪里&lt;/h2&gt;
&lt;p&gt;放在它自己独立的页面上，配一个稳定的网址，并作为一个订阅源来分发。如果被埋在某个设置菜单里，或者藏在代码托管平台的一个发布标签下面，它就只能触达那些原本就已经知道该去哪里找的人。一个公开的页面可以从工单里被链接过来，可以被一篇评测引用，也可以被人订阅。订阅源和页面本身同样重要：一个每月才去检查一次产品体验日志的读者是很少见的，一个订阅了它的读者却不是，而只有订阅源才能真正服务好后面这一类人。&lt;/p&gt;
&lt;h2&gt;它和发布说明到底有什么不同&lt;/h2&gt;
&lt;p&gt;这两者总是被人不断混淆，而它们之间的差别又足够大，大到把二者混在一起会产生一份对两类读者都没有真正服务好的文档。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志对比发布说明&lt;/a&gt;会把这个区别完整地讲一遍；简单来说，体验日志是完整的、按时间顺序排列的记录，而发布说明则是一份经过精心筛选的子集，专门写来让一次更新听起来值得拥有。一个产品通常两者都需要，分别对应读者一天当中不同的时刻。&lt;/p&gt;
&lt;h2&gt;什么才让一份体验日志值得一读&lt;/h2&gt;
&lt;p&gt;对自身覆盖范围的具体和诚实。&amp;quot;各种缺陷修复&amp;quot;这句话，会教会读者不再打开这个页面，因为它没有承诺任何她可以核实的东西。即使只是一个很小的修复，一条能准确说出到底哪个具体行为发生了变化的记录，才是能让订阅一直保持活跃的那种记录。这份自律同样适用于被省略的内容：一份只发布胜利、却从来不发布对某个曾经损坏的东西所做修复的体验日志，读起来就像披着体验日志外衣的营销文案，而读者是会察觉到这一点的。&lt;/p&gt;
&lt;p&gt;版本管理上的自律同样重要。&lt;a href=&quot;https://changeloop.dev/blog/zh/semantic-versioning-changelog/&quot;&gt;语义化版本控制和你的体验日志&lt;/a&gt;展示了版本号和记录应该如何对应起来，这样一个正在浏览版本历史的读者，得到的就是同一个信号被重复了两次，而不是两个互相矛盾的信号。&lt;/p&gt;
&lt;h2&gt;体验日志到底是怎么生成出来的&lt;/h2&gt;
&lt;p&gt;有两种方式，而绝大多数真实的配置其实都是两者的混合。自动化生成会读取提交信息，通常采用&lt;a href=&quot;https://www.conventionalcommits.org/en/v1.0.0/&quot;&gt;Conventional Commits&lt;/a&gt;格式，然后在没有任何人碰过输出结果的情况下，把它们转换成一条条记录；&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;从 Conventional Commits 到体验日志&lt;/a&gt;覆盖了这整条流水线。经过筛选的生成方式，意味着有人会手动写出或者编辑每一条记录。自动化的输出速度更快，也绝不会漏掉任何一个已经合并的拉取请求，但它会把每一条含糊不清的提交信息原封不动地继承下来，所以大多数采用自动化的团队，最终还是会在发布之前保留一道轻量的编辑环节，而不是直接把未经处理的原始输出展示出来。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;是不是每一个产品都需要一份体验日志?&lt;/strong&gt;
只要有用户会受到变化影响，任何产品都需要，不管它是一款 SaaS 应用、一个内部工具，还是一个公开的 API。形式会随之调整（API 体验日志的读法和面向消费者的应用完全不同），但这份需求本身并不会变。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;用软件领域的说法来讲，体验日志到底是什么?&lt;/strong&gt;
和上面完全一样的定义：一份按日期排列、按时间顺序记录软件发生了哪些变化的清单，它是写给使用这个软件的人看的，而不是写给构建它的人看的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志能不能从提交记录里自动生成出来?&lt;/strong&gt;
可以，而且很多团队做的正是这件事，通常是从 Conventional Commits 格式的提交信息里生成。这样做的代价在于，一条自动生成的记录，清晰程度完全取决于它所来源的那条提交信息有多清晰，所以在发布之前的一道审阅环节，就是用来专门抓出那些需要重新措辞的记录的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志和版本历史是同一回事吗?&lt;/strong&gt;
两者之间足够接近，接近到这两个说法经常被人互相换着用。版本历史有时候只是一份不带任何描述的版本号加日期的清单；而体验日志则始终会包含到底发生了什么变化这一点。&lt;/p&gt;
</content:encoded></item><item><title>Webhook 体验日志：没人要求过的破坏性变更</title><link>https://changeloop.dev/blog/zh/webhook-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/webhook-changelog/</guid><description>Webhook payload 的改动会悄无声息地出问题，因为没人能拒绝它，接收方可能带着错误值运行好几天。本文讲什么样的改动算破坏性变更，以及如何加版本并安全迁移。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;REST API 的体验日志之所以存在，是因为调用方可以选择拒绝一个自己看不懂的响应，或者至少把错误记得足够响亮，让某个人注意到。Webhook 的接收方几乎两样都做不到。它收到一个 POST，读取自己期望的字段，如果某个字段挪动了位置、换了类型，或者干脆消失了，那个端点要么在没人盯着的后台任务里悄悄崩掉，要么更糟，带着一个从未验证过的错误值继续跑下去。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是破坏性变更&lt;/a&gt;讲的是通用定义；webhook 的 payload 需要自己的一套答案，因为它失败的方式和一个被人有意调用的端点完全不同。&lt;/p&gt;
&lt;h2&gt;为什么 webhook payload 的改动，坏起来会跟 API 响应的改动不一样&lt;/h2&gt;
&lt;p&gt;因为请求的方向是反过来的。REST 的调用方发起调用，可以加一个版本头，可以在 4xx 时重试，也可以在响应里读到一条弃用提示。Webhook 的接收方对这一切都没有主动权：是你的服务器决定要发送、决定什么时候发送、决定 body 会长成什么样子。接收方唯一的手段，就是搭建集成时自己写下的那点校验逻辑，而大多数集成都是搭好一次、能跑起来，然后就没人再回头看，直到它坏掉为止。这种不对称，正是 webhook payload 的改动比调用方主动请求的响应体里的同样改动更值得谨慎对待的全部理由。&lt;/p&gt;
&lt;h2&gt;在 webhook payload 里，到底什么才算真正的破坏性变更&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;th&gt;对大多数接收方而言是否破坏性&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;新增一个字段&lt;/td&gt;
&lt;td&gt;不算，前提是接收方会忽略未知字段（这个假设要验证，不能想当然）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;删除一个字段&lt;/td&gt;
&lt;td&gt;算，只要有任何东西在读它&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;重命名一个字段&lt;/td&gt;
&lt;td&gt;算，本质上就等于删掉了旧字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;改变字段类型（字符串变对象）&lt;/td&gt;
&lt;td&gt;几乎总是算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;调整 JSON body 里字段的顺序&lt;/td&gt;
&lt;td&gt;不算，对任何按 key 解析的接收方来说都不算（本该所有接收方都是这样）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;改变事件名称或类型&lt;/td&gt;
&lt;td&gt;算，只要接收方据此做过滤或路由&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&amp;quot;新增字段是安全的&amp;quot;这一行，恰恰是团队最依赖、也最值得去验证而不是想当然的一行。宽松的 JSON 解析器默认会忽略未知字段，但一个反序列化到严格 schema 的接收方，好几种带类型的语言不需要额外配置就会这么做，一旦出现意外字段就可能拒绝整个 payload。新增字段对你的 webhook 而言是否安全，取决于你是否清楚接收方是怎么解析的，而不是因为 JSON 本身天生宽容。&lt;/p&gt;
&lt;h2&gt;该怎么给 webhook payload 加上版本&lt;/h2&gt;
&lt;p&gt;和 API 响应的情况大体一样，只是多了一个细节：接收方从来不发请求，所以它没法要求某个版本，只能由发送方来声明。版本可以放在正文里，也可以放在这次投递本身的请求头里；&lt;a href=&quot;https://docs.github.com/en/webhooks/webhook-events-and-payloads&quot;&gt;GitHub 的投递&lt;/a&gt;带有 &lt;code&gt;X-GitHub-Event&lt;/code&gt; 和 &lt;code&gt;X-GitHub-Hook-ID&lt;/code&gt;，而 &lt;a href=&quot;https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md&quot;&gt;Standard Webhooks 规范&lt;/a&gt;则把它的元数据放在 &lt;code&gt;webhook-*&lt;/code&gt; 请求头里。在 payload 里放一个版本字段（&lt;code&gt;&amp;quot;payload_version&amp;quot;: 2&lt;/code&gt;）是最省成本的选择，只要接收方愿意据此分支处理就能用。带版本的事件类型（&lt;code&gt;invoice.updated&lt;/code&gt; 变成 &lt;code&gt;invoice.updated.v2&lt;/code&gt;，作为一个接收方自愿订阅的独立事件）搭建起来更费工夫，但意味着旧的形态会继续流向那些从未迁移过的人，这一点在这里比在 REST 端点上更要紧，因为你没法一个个打电话让每个接收方去更新。在注册 webhook 端点时选定的按订阅设置，把决定提前做好，而不是在每次投递时都要分支，当你手头已经有一份订阅记录可以挂靠时，这是正确的选择。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /receiver-endpoint
{
  &amp;quot;event&amp;quot;: &amp;quot;invoice.updated&amp;quot;,
  &amp;quot;payload_version&amp;quot;: 2,
  &amp;quot;data&amp;quot;: { &amp;quot;invoice_id&amp;quot;: &amp;quot;inv_123&amp;quot;, &amp;quot;status&amp;quot;: &amp;quot;paid&amp;quot; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;你连谁在监听都不一定知道，该怎么办&lt;/h2&gt;
&lt;p&gt;比 API 体验日志里同类问题还要棘手，因为 webhook 在你这一侧根本没有一份能叫出调用方名字的入站请求日志；你只有自己那份出站投递日志，它只能告诉你某个端点收到了 200，却不会告诉你对方拿这个 body 做了什么。至少要追踪两件事：每一个登记在案、有明确负责人的端点，这和&lt;a href=&quot;https://changeloop.dev/blog/zh/internal-api-changelog/&quot;&gt;内部 API 体验日志&lt;/a&gt;给内部消费者的建议是同一种纪律，以及改动 payload 之后每个端点的投递失败率。改动之后不久，某个端点的 4xx 或 5xx 响应突然飙升，是你能拿到的最接近堆栈跟踪的信号了，而且往往是唯一的信号，能告诉你某个接收方已经坏了，因为运营它的那个团队可能好几天都察觉不到。&lt;/p&gt;
&lt;h2&gt;Webhook 的体验日志该不该和 API 体验日志分开&lt;/h2&gt;
&lt;p&gt;同一页面里的一个独立板块，而不是一份独立的发布物。&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志&lt;/a&gt;已经确立了谁会读它、怎么订阅它；webhook payload 的改动属于同一条信息流，只要标记得足够清楚，接收方那边的开发者在扫描&amp;quot;这会不会影响我的集成&amp;quot;时就能筛选出来，因为 webhook 的消费者往往没有别的理由去查一份通用的 API 体验日志，只有在有人直接把她引导过去时才会找到它。&lt;/p&gt;
&lt;h2&gt;Webhook payload 一个合理的弃用窗口该是什么样子&lt;/h2&gt;
&lt;p&gt;要比同等的 REST 弃用期更长，因为接收方那边的迁移，通常意味着一个你可能没有直接联系方式的第二个团队，得靠自己注意到这件事、规划它、在没有任何自身紧迫感的情况下把它上线。对一个接收方大概率还在用宽松库解析的字段来说，一个月是合理的下限；对一个严格 schema 会彻底拒绝的字段删除来说，三个月或更长会更安全。在可行的情况下，把旧形态和新形态在整个窗口期内一起发送（旧的 &lt;code&gt;status&lt;/code&gt; 字段和它在版本 2 里的替代字段放在同一个 payload 里），因为读旧字段的接收方不用碰自己的代码就能继续运行，而已经迁移完的接收方只是简单地忽略掉那个自己不再需要的字段。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Webhook 消费者需要在 payload 改动上线前确认吗?&lt;/strong&gt;
默认根本不存在这样的确认机制，正因如此弃用窗口在这里才比在 REST API 上更重要：没有人会确认自己准备好了，所以窗口必须足够长，长到大多数接收方能按自己的节奏完成迁移，然后旧形态才消失。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;什么时候添加未知字段而不通知才算安全?&lt;/strong&gt;
只有在你验证过，而不是想当然地认为你的接收方是宽松解析的之后。一条体验日志条目成本很低，却能消除猜测；靠&amp;quot;JSON 解析器会忽略多余字段&amp;quot;这种假设悄悄加字段，会破坏任何使用严格反序列化的接收方。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;payload 改动后，检测出坏掉的 webhook 接收方最快的方式是什么?&lt;/strong&gt;
改动之后几个小时内观察到的、按端点统计的投递失败率。它不会告诉你到底坏了什么，只会告诉你有东西坏了，但这是你能拿到的最早、往往也是唯一的信号。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;重试逻辑能帮助接收方挺过一次 payload 改动吗?&lt;/strong&gt;
不能。重试只是把同一个新 payload 再发一遍，不会退回到接收方能解析的旧形态。payload 改动会在第一次投递和之后每一次重试里，用完全相同的方式弄坏接收方。&lt;/p&gt;
</content:encoded></item><item><title>API 体验日志: 该公开什么内容，又是谁在读它</title><link>https://changeloop.dev/blog/zh/api-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/api-changelog/</guid><description>API 体验日志的读者，是要判断自己的代码下个月还能否正常运行的人。本文说明每条记录应该交代什么、放在哪里，以及调用方如何订阅。</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;API 体验日志是对调用方可能注意到的每一个变更所做的、带有日期的记录，它是写给那些正在与这个 API 进行集成的人的，而不是写给发布它的团队的。正是这样的读者群体，让它成为一份和产品体验日志完全不同的文档：读者正在判断自己的代码下个月是否还能正常运行。大多数 API 体验日志都以同样的方式失败了，它们成了内部发布信息流的一份经过过滤的副本，于是一个被删除的字段和一处文字修正被摆在了同样的权重上，而这两者最终都没有人真正去读。&lt;/p&gt;
&lt;h2&gt;API 体验日志到底是什么&lt;/h2&gt;
&lt;p&gt;它是一份公开的、带有日期的日志，记录着一个别人已经为之编写过代码的接口发生了哪些变更。判断某样东西是否应该被收录进去的一个有用的测试，和这个变更在内部到底有多大完全没有关系。这个测试问的是：一个去年就已经写好、此后从未被改动过的正确的调用方，会不会因为这个变更而表现出不同的行为。这个测试会接受一些非常微小的变更，也会排除一些非常巨大的变更。&lt;/p&gt;
&lt;p&gt;以下所有内容，默认前提是调用方身处公司之外，而且除了这份文档之外，实际上根本没法被联系上。当调用方是同一家公司里的另一个团队时，这笔账会变得完全不同，值得单独拿出来讲一讲；&lt;a href=&quot;https://changeloop.dev/blog/zh/internal-api-changelog/&quot;&gt;对内 API 体验日志&lt;/a&gt; 讲解了这群读者到底需要的是什么。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文档&lt;/th&gt;
&lt;th&gt;读者&lt;/th&gt;
&lt;th&gt;回答的问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;API 体验日志&lt;/td&gt;
&lt;td&gt;调用 API 的开发者&lt;/td&gt;
&lt;td&gt;我的集成还能正常工作吗?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发布说明&lt;/td&gt;
&lt;td&gt;产品的用户&lt;/td&gt;
&lt;td&gt;我现在能做什么以前做不到的事?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;停用通知&lt;/td&gt;
&lt;td&gt;调用某个具体事物的人&lt;/td&gt;
&lt;td&gt;这个东西什么时候会停止工作?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;状态页面&lt;/td&gt;
&lt;td&gt;当前正受到影响的任何人&lt;/td&gt;
&lt;td&gt;现在是不是宕机了?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;迁移指南&lt;/td&gt;
&lt;td&gt;正在升级的调用方&lt;/td&gt;
&lt;td&gt;我该怎样从 A 迁移到 B?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;a href=&quot;https://changeloop.dev/blog/zh/api-migration-guide/&quot;&gt;怎样写一份真正管用的 API 迁移指南&lt;/a&gt; 完整讲解了这最后一份文档；简单来说，这正是一条不兼容变更记录理应链接过去、而不是试图去替代的东西。&lt;/p&gt;
&lt;p&gt;这五者是各自拥有独立生命周期的独立文档。停用通知是一个带有日期的承诺，它也应该被收录进体验日志，但体验日志的一条记录只会被写一次，而一个停用计划则会被一直追踪到它的服务下线为止。把两者混为一谈，正是服务下线日期常常被错过的原因。&lt;/p&gt;
&lt;h2&gt;一条记录里应该包含什么&lt;/h2&gt;
&lt;p&gt;六件事，而前三件恰恰是通常缺失的那些。这个变更本身，用请求或响应的角度来表述，而不是用内部组件的角度。它是否会破坏一个正确的调用方。调用方需要做什么，包括&amp;quot;什么都不需要做&amp;quot;。它生效的日期。受影响的版本或版本范围。如果存在迁移指南，还需要附上链接。&lt;/p&gt;
&lt;p&gt;一条写着&amp;quot;改进了 accounts 端点&amp;quot;的记录，在这六项上全部失败了。一条写着&amp;quot;&lt;code&gt;accounts.type&lt;/code&gt; 字段现在返回 &lt;code&gt;individual&lt;/code&gt;，而它以前返回的是 &lt;code&gt;personal&lt;/code&gt;；对于 9 月 2 日之前创建的账户，现有的值不会发生变化；除非你在比较这个字符串本身，否则不需要采取任何行动&amp;quot;的记录，用一句话就回答了全部六项。&lt;/p&gt;
&lt;p&gt;请按后果而不是按部门来对记录进行分类。三个标签几乎承载了全部价值：breaking、additive 和 fixed。&lt;a href=&quot;https://semver.org/&quot;&gt;Semantic Versioning&lt;/a&gt; 已经精确定义了前两者，借用它的定义而不是自己另行发明，意味着一个了解 semver 的读者也能理解你的标签。如果你愿意，&lt;a href=&quot;https://keepachangelog.com/en/1.1.0/&quot;&gt;Keep a Changelog&lt;/a&gt; 提供了一套更长的标签体系，而它的核心原则在这里比在任何其他地方都更加成立：日志是写给人看的，一堆提交标题的堆砌绝不是。&lt;/p&gt;
&lt;h2&gt;API 体验日志和发布说明到底有什么不同&lt;/h2&gt;
&lt;p&gt;发布说明描述的是产品现在能做什么。API 体验日志描述的是契约现在是什么样子。同一份已经发布的工作，往往会在两边都产生一条记录，但表述方式各不相同，因为不同的读者需要的东西也不一样：一种新的导出格式，对用户来说是一项功能，但对于依赖那个字段来分支处理的调用方来说，则是一个新的枚举值。&lt;/p&gt;
&lt;p&gt;由此带来的实际后果是，这两者不能是同一条信息流，只是换了个样式而已。一个订阅了你发布的所有内容的调用方，最终会取消订阅，然后就会错过那个破坏性变更。如果你只发布一条信息流，请对它做过滤；如果你发布两条，请让 API 那条更窄，并且永远不要让市场营销类的记录混进去。我们在&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明的对比&lt;/a&gt;一文中并排比较了这两种形式。&lt;/p&gt;
&lt;h2&gt;API 体验日志应该放在哪里&lt;/h2&gt;
&lt;p&gt;放在参考文档旁边，使用一个稳定的 URL，让每一条记录都能通过一个片段或者一个独立的路径被单独寻址。调用方会在事故复盘和内部工单中引用这些记录，而一条无法被链接的记录，最终只会被人当作截图贴出来。&lt;/p&gt;
&lt;p&gt;除了做成一个页面之外，也请把它作为一份机器可读的输出来发布。一条遵循 &lt;a href=&quot;https://www.jsonfeed.org/version/1.1/&quot;&gt;JSON Feed 规范&lt;/a&gt; 的 JSON feed，或者一条 &lt;a href=&quot;https://www.rssboard.org/rss-specification&quot;&gt;RSS feed&lt;/a&gt;，一旦记录变成了结构化的数据，就几乎不需要额外成本，而这正是让客户能够把你的变更纳入他们自己发布流程的关键。这也决定了是否会有人在此基础上进行构建。GitHub 出于同样的原因，把它的 &lt;a href=&quot;https://docs.github.com/en/rest/about-the-rest-api/api-versions&quot;&gt;REST API 版本&lt;/a&gt; 文档放在参考文档的正旁边：版本策略本身就是接口的一部分。&lt;/p&gt;
&lt;h2&gt;一条好的记录在实践中是什么样子&lt;/h2&gt;
&lt;p&gt;同一周内的三条记录，采用上面描述的格式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026-09-02  Breaking  v2
  `POST /invoices` 现在会拒绝与客户账户货币不一致的 `currency`，
  返回 422 而不是默默地进行转换。依赖过转换行为的调用方，
  必须改为发送账户所使用的货币。只影响 v2；v1 在 2027-01-15
  的服务下线日期之前不会发生变化。

2026-09-02  Additive  v1, v2
  `Invoice` 新增了一个 `settled_at` 时间戳字段，在发票结清之前
  该字段为 null。不需要采取任何行动。拒绝未知字段的客户端应该
  进行更新。

2026-08-31  Fixed  v2
  `GET /invoices?status=` 之前对未知状态返回的是一个空页面，
  而不是 400。现在会返回 400，并附上被接受的合法取值。之前
  拼写出错的调用方本来看到的是零条结果，现在会看到一个错误。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第三条是最常被省略的一种，因为在内部看来它只是一次 bug 修复。但对于已经围绕那个空页面构建了重试逻辑的调用方来说，这其实是一次行为变更，而这条记录正是能阻止支持工单产生的东西。标签写着 fixed，正文说明的是调用方可能会注意到什么，正是这种区分，让整份日志在不把每一次修复都夸大成破坏性变更的前提下，保持了诚实。&lt;/p&gt;
&lt;h2&gt;调用方应该如何订阅它&lt;/h2&gt;
&lt;p&gt;给他们不止一个渠道，因为他们的任务各不相同。给想要一切信息的开发者提供一条信息流。给只想要破坏性变更的人提供邮件。给代码本身提供响应头，它是唯一一个永远不会忘记检查的订阅者：&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8594&quot;&gt;RFC 8594 中定义的 &lt;code&gt;Sunset&lt;/code&gt; 头&lt;/a&gt; 会把服务下线日期放进响应里，客户端库可以据此把它记录下来。&lt;/p&gt;
&lt;p&gt;大多数团队会遗漏的渠道，是直接联系。如果某个调用方上周刚好用过你正要改动的那个字段，你其实知道那是谁，而给这些账户发一封邮件，价值要远远高于任何一次广播式的通知。这和&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;闭合客户反馈循环&lt;/a&gt;遵循的是同一种纪律，只不过应用在了一个没有人主动要求过的变更上：受影响的人会被单独通知，其余所有人则会收到信息流。Webhook 是第四条渠道，有它自己的失败方式，值得在依赖它之前先弄清楚：&lt;a href=&quot;https://changeloop.dev/blog/zh/webhook-changelog/&quot;&gt;webhook 体验日志&lt;/a&gt;讲的是为什么那边的 payload 改动会悄悄坏掉，根本没有调用方能拒绝那个新形态。&lt;/p&gt;
&lt;h2&gt;该如何为一个破坏性变更编写记录&lt;/h2&gt;
&lt;p&gt;请从这个变更本身的破坏性说起，而不是从原因说起。一个正在浏览十条记录的调用方，必须在第一句话里就知道这一条会不会给他带来额外的工作。然后才是日期、受影响的版本、迁移方式，以及旧行为如果是要消失而不是变化，那对应的最后期限是什么。&lt;/p&gt;
&lt;p&gt;请把同样的内容，以一致的措辞，分别放进停用通知、响应头和直接邮件之中，并且给这四者设定同一个日期。它们之间的不一致，正是把一次本该有计划的变更变成一场事故的失误，因为一个只读到其中一份内容的调用方，会依据错误的日期采取行动。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是破坏性变更&lt;/a&gt;一文讨论了这个决定本身，而&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;如何停用一个 API&lt;/a&gt;则讨论了随之而来的时间安排。&lt;/p&gt;
&lt;p&gt;在 changeloop 中，当一个 pull request 被合并、有人编辑并批准了草稿之后，一次 API 变更就会成为一条记录，而这条记录会发布到&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;信息流和小组件&lt;/a&gt;上。同一时刻，如果某位调用方的小组件反馈变成了 GitHub issue，而这个 pull request 关闭了该 issue，那位调用方就会在那个 issue 上收到通知。真正重要的是审核这一步：API 体验日志是一份具有契约性质的文档，任何草稿都不应该在没有人真正读过之前，就到达调用方手中。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;每一次 API 变更都需要一条体验日志记录吗?&lt;/strong&gt;
只要是一个正确的调用方可能会注意到的变更，就需要，哪怕你自己认为它是内部性质的。对请求或响应没有可观察影响的变更则不需要，把这类变更也加进去，只会训练读者养成一目十行的习惯。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;API 体验日志应该放在文档里，还是放在市场营销网站上?&lt;/strong&gt;
放在文档里，就在参考文档旁边。读者通常本来就已经在那里了，而市场营销网站上的体验日志，往往会吸引到一批它原本并不是为之而写的读者。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;它应该往回追溯到多久以前?&lt;/strong&gt;
无限期。这些记录会在多年之后依然被人在事故复盘中引用，而一份被截断的日志会把这些链接全部弄断。请用分页，而不是删减。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我需要为每一个 API 版本都维护一份单独的体验日志吗?&lt;/strong&gt;
不需要。一份日志里每条记录带一个版本字段，会更容易阅读，也更容易搜索。按版本过滤是页面本身应该具备的功能，而不是把文档拆开的理由。&lt;/p&gt;
</content:encoded></item><item><title>如何搭建一个人们真的会持续关注的体验日志页面</title><link>https://changeloop.dev/blog/zh/changelog-page/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/changelog-page/</guid><description>体验日志页面只有让人一次次回来看，才值得认真搭建。本文说明它放在哪里、每条记录需要什么内容、信息流和标记语言怎么处理，以及小组件放在哪。</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一个体验日志页面，只有在人们真的会回来看它的时候，才算得上真正值得去搭建。这是一个比单纯拥有它要高得多的标准，而大多数页面恰恰就是在这个标准上跌了跤：页面确实存在，footer 里也确实有一个链接，更新起来一阵一阵的，除了出事故的时候几乎没有人会去看它。真正把这两者区分开来的那些决定，早在任何东西被写出来之前就已经做完了，而这些决定所关心的，主要是这个页面到底应该放在哪里，以及从同一份内容里还能生成出什么别的东西。&lt;/p&gt;
&lt;h2&gt;体验日志页面到底是什么&lt;/h2&gt;
&lt;p&gt;它是一份公开的、带有日期的列表，记录着一个产品到底发生了什么变化，放在一个属于你自己的 URL 上。它是可以承载同一批记录的五种表现形式之一，而真正有用的问题，从来都不是该选哪一种，而是哪一种才是最权威的那一份，哪些又只是从它那里生成出来的。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;表现形式&lt;/th&gt;
&lt;th&gt;最适合用于&lt;/th&gt;
&lt;th&gt;成本&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;托管页面&lt;/td&gt;
&lt;td&gt;搜索、链接、长期的历史记录&lt;/td&gt;
&lt;td&gt;一个 URL 和一份模板&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;应用内小组件&lt;/td&gt;
&lt;td&gt;触达那些永远不会主动访问页面的用户&lt;/td&gt;
&lt;td&gt;一次嵌入，以及足够的克制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文档中的一个板块&lt;/td&gt;
&lt;td&gt;面向 API 和开发者的读者群体&lt;/td&gt;
&lt;td&gt;把它放在参考文档旁边&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON feed&lt;/td&gt;
&lt;td&gt;在你的变更之上继续构建的客户&lt;/td&gt;
&lt;td&gt;你早已具备的结构化数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS feed&lt;/td&gt;
&lt;td&gt;只订阅一次的开发者&lt;/td&gt;
&lt;td&gt;几乎没有任何成本&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;请选定一个权威的信息来源，只发布一次，剩下的全部从那里自动生成出来。那些用手工分别维护页面和小组件的团队，最终往往会得到两份对不上的文本，而这种不一致，最后总是由客户来替你发现。&lt;/p&gt;
&lt;h2&gt;体验日志页面到底应该放在哪里&lt;/h2&gt;
&lt;p&gt;放在你自己的域名下，放在一个稳定的路径上，让每一条记录都能通过一个片段或者一个独立的路径被单独定位。人们会在事故复盘和内部工单里引用这些记录，而一条无法被直接链接的记录，最终只会被当成一张截图贴出来。&lt;/p&gt;
&lt;p&gt;三种常见的放置方式，分别是主站上的一个路径、一个子域名，以及文档里的一个板块。主站上的一个路径，是那个默认应该被反对的选项，而不是默认应该被支持的选项：它继承了整个站点的权威性，不需要额外的证书或 DNS，还能让这个页面和其他一切内容保持在同一套导航体系之下。&lt;/p&gt;
&lt;p&gt;一个子域名之所以会成为正确的答案，是因为这个页面由一套和市场营销站点完全不同的系统来提供服务，否则你就只能去做反向代理了。它的代价，是权威性会被分开累积。把体验日志放进文档里之所以是正确的，是当读者群体是开发者的时候，原因在&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志&lt;/a&gt;一文中已有说明：读者往往本来就已经在那里了。&lt;/p&gt;
&lt;p&gt;比选择本身更重要的是，记录必须能够被单独链接。人们会在事故复盘和内部工单里引用记录，而一条只能被描述成&amp;quot;体验日志，往下滚动&amp;quot;的记录，最终只会被当成截图贴出来，而不是被真正链接过去。&lt;/p&gt;
&lt;h2&gt;体验日志页面到底需要什么&lt;/h2&gt;
&lt;p&gt;五件事，而大多数页面恰恰在前两件上就出了问题。按变更逐条记录，并带有日期，最新的排在最前面。每条记录都配一个分类或者标签，好让读者能按感兴趣的类型去扫读。每条记录都有一个永久链接。一个订阅的入口。超过大约五十条记录之后，还需要一个搜索或者过滤功能。&lt;/p&gt;
&lt;p&gt;剩下的都是可选的。截图有帮助，但也需要维护成本。作者署名在某些产品上能建立信任，在另一些产品上却只是噪音。版本号对某个 API 的调用方很重要，对几乎其他所有人都不重要。&lt;a href=&quot;https://keepachangelog.com/en/1.1.0/&quot;&gt;Keep a Changelog&lt;/a&gt; 如果你没有理由自创一套标签，就是一个相当合理的默认选择，而它的核心原则，即便你抛弃了其余的一切，也依然值得保留：日志是写给人看的。&lt;/p&gt;
&lt;p&gt;如果你的产品是持续发布的，请按日期分组，而不是按版本分组。一个正在扫读&amp;quot;这是在我们九号那次事故之前还是之后&amp;quot;的读者，找的其实是一个日期，而一个按版本号来组织的页面，只会逼着他去做算术。&lt;/p&gt;
&lt;h2&gt;应该做成一个页面，还是应该做成一个应用内小组件&lt;/h2&gt;
&lt;p&gt;两者都要，但都从同一个信息来源生成。页面是搜索、链接和长期历史记录所在的地方。小组件则是你触达那些永远不会主动访问页面的绝大多数用户的方式，它之所以有效，是因为它出现在了用户已经在使用的那个产品里面。&lt;/p&gt;
&lt;p&gt;小组件真正的失败方式，是打扰。一个要求用户为每一条记录都付出注意力的小红点，一周之内就会被永久性地无视掉，而这样一来，你就失去了那条真正重要的记录本该拥有的渠道。请从读者上一次查看之后开始计算未读数量，在第一次访问时就默默地把计数器种下去，这样才不会有人一上来就被一整年的历史记录堆出来的小红点吓到，并且要让读者自己去打开它，而不是替他打开。&lt;/p&gt;
&lt;h2&gt;如何让体验日志页面变得机器可读&lt;/h2&gt;
&lt;p&gt;请把同一批记录也发布成一条信息流。一条遵循 &lt;a href=&quot;https://www.jsonfeed.org/version/1.1/&quot;&gt;JSON Feed 规范&lt;/a&gt; 的 JSON feed，对于任何用代码去消费它的场景来说，都是摩擦最小的那个选项，而一条 &lt;a href=&quot;https://www.rssboard.org/rss-specification&quot;&gt;RSS feed&lt;/a&gt;，则是一个在阅读器里订阅的开发者会期待看到的东西。一旦记录变成了结构化的数据，而不是手写的 HTML，这两者的成本都会很低，而这正是应该把权威副本保持结构化的真正原因。&lt;/p&gt;
&lt;p&gt;也请给页面本身加上标记。每一条记录都是一件带有日期和标题的作品，而 &lt;a href=&quot;https://schema.org/CreativeWork&quot;&gt;schema.org&lt;/a&gt; 提供了相应的词汇表。这样做的理由和永久链接是一样的：它能让这个页面被那些并非浏览器的东西所使用，其中也包括客户自己的发布流程。如果底层的这些记录一开始就从来不是结构化数据，上面这一切都无从谈起；&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-file-formats/&quot;&gt;体验日志文件格式&lt;/a&gt;讲的正是 Markdown、JSON 和 YAML 各自要付出什么代价，才能成为这条信息流和这份标记真正被生成出来所依据的那个真相来源。&lt;/p&gt;
&lt;h2&gt;体验日志页面对 SEO 到底有没有帮助&lt;/h2&gt;
&lt;p&gt;间接地有，而且见效很慢。单独的一条记录很少能够排到搜索结果前面，因为它并没有针对任何一个真实存在的搜索词。这个页面真正靠的是链接来赢得自己的地位：这些记录会被引用在客服的回复里、论坛帖子里、事故复盘报告里，而这些链接又会不断累积到一个属于你自己的 URL 上。一个持续两年、每周都在更新的页面，本身也是它所属产品的一个可信的、新鲜度信号。&lt;/p&gt;
&lt;p&gt;真正不奏效的做法，是把这些记录当成内容营销来对待。一条为了凑字数而被硬塞成三段的记录，在它真正该做的那件事上反而会做得更差，那件事就是用一句话告诉读者，他正在用的某个东西是不是发生了变化。如果你希望体验日志能够真正支持搜索，请把精力放在永久链接、信息流，以及指向它的内部链接上，并且让每条记录都保持简短。我们自己的&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;页面，收集的正是一批把这种平衡拿捏得很好的页面。&lt;/p&gt;
&lt;h2&gt;人们到底是怎样订阅的&lt;/h2&gt;
&lt;p&gt;给他们提供他们本来就已经在用的那些渠道：给开发者提供 RSS 或 JSON feed，给那些只想听到重要事情的人提供邮件，给那些两者都永远不会去做的人提供应用内小组件。请去问他们到底想听到什么，而不是自己去假设，因为一个想要破坏性变更、结果却收到一堆文字修正的读者，最终会把两个渠道都取消订阅。&lt;/p&gt;
&lt;p&gt;最后一个应该被加上的渠道，是能真正闭合整个循环的那一个。当一条记录解决了某个具体的人所提出的具体请求时，请直接告诉他，而不是指望他自己会去读那个页面。在 changeloop 里，一条记录会同时发布到&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;页面、信息流和小组件&lt;/a&gt;上，而如果某个人的小组件反馈变成了 GitHub issue，并被这个 pull request 关闭，他就会在那个 issue 上连同一条指向该记录的链接一起收到通知，还会在小组件里看到这条记录。这套机制和任何一次订阅本质上都是一样的；唯一的区别，是这个接收者其实早就已经问过了。这正是在&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;从体验日志这一侧闭合反馈循环&lt;/a&gt;一文中展开的论点。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;体验日志页面应该放在子域名上，还是放在一个路径下?&lt;/strong&gt;
默认应该放在主站的一个路径下，因为它能继承整个站点的权威性，也不需要额外的基础设施。只有当页面确实是由另一套不同的系统来提供服务时，子域名才是合理的选择。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;页面一次应该展示多少条记录?&lt;/strong&gt;
足够填满一屏就行，不要更多，之后再用分页来处理。把两年的历史记录一次性加载进同一份文档里，速度会很慢，也会让人更难找到最新的那一条。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;旧的记录到底应不应该被删除?&lt;/strong&gt;
不应该。它们会被你站点之外的地方引用，删掉之后链接就会失效。请就地对某条记录做修正，并附上一条说明，同时让那个 URL 继续保持可访问。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;是不是每一次变更都必须出现在这个页面上?&lt;/strong&gt;
只有用户可能会注意到的那些才需要。一个连内部重构都记录进去的页面，只会训练读者养成一目十行的习惯，而一个被一目十行读过去的页面，恰恰会在它真正承载了紧急信息的那一天彻底失效。&lt;/p&gt;
</content:encoded></item><item><title>真正会被完整读完的产品更新邮件模板到底长什么样</title><link>https://changeloop.dev/blog/zh/product-update-email/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/product-update-email/</guid><description>会被读完的邮件，是发给提过请求的人的那一封。本文说明模板、四种更新邮件、标题写法、受众细分与用户同意。</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一封真正会被读完的产品更新邮件，正是那种被发给了一个人，而这个人恰好就提出过邮件里所宣布的那个具体请求的邮件。除此之外的一切，都在和收件箱里剩下的所有内容争夺读者的注意力，而这样一场竞争，一条发布公告在大多数星期里都是会输掉的。仅仅这一个事实，就应该在任何一句具体的措辞被写下来之前，先决定这封邮件到底应该长成什么样子：到底是谁会收到它，以及这个人到底做了什么才会出现在这份名单上。&lt;/p&gt;
&lt;h2&gt;产品更新邮件到底是什么&lt;/h2&gt;
&lt;p&gt;它是一条消息，用来告诉已经在使用某个产品的现有用户，这个产品到底发生了什么变化。它一共有四种彼此不同的类型，而把它们全部当成同一份名单来对待，正是打开率不断下滑的原因。每一种类型都有各自不同的触发条件、不同的受众，以及不同的、可以被接受的发送频率。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;触发条件&lt;/th&gt;
&lt;th&gt;受众&lt;/th&gt;
&lt;th&gt;频率&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;定向通知&lt;/td&gt;
&lt;td&gt;某个人提出的具体请求已经发布上线&lt;/td&gt;
&lt;td&gt;一个人&lt;/td&gt;
&lt;td&gt;每次发生时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;破坏性变更通知&lt;/td&gt;
&lt;td&gt;一次会让读者付出额外工作量的变更&lt;/td&gt;
&lt;td&gt;仅限受影响的账户&lt;/td&gt;
&lt;td&gt;每次发生时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;摘要邮件&lt;/td&gt;
&lt;td&gt;时间的推移本身&lt;/td&gt;
&lt;td&gt;已经选择订阅的用户&lt;/td&gt;
&lt;td&gt;最多每月一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上线公告&lt;/td&gt;
&lt;td&gt;一次值得打断用户的正式上线&lt;/td&gt;
&lt;td&gt;某个细分群体或全体用户&lt;/td&gt;
&lt;td&gt;很少发生，也应该让人感觉很少发生&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;大多数团队只会去构建第三种类型，把它发给所有人，然后得出产品更新邮件根本不管用这样的结论。而前两种类型，才真正承载了几乎全部的价值，因为读者早就已经有了一个在先的、真实存在的理由去关心这件事，而这封邮件正是在那个理由还依然鲜活的时候被送到的。&lt;/p&gt;
&lt;p&gt;这里列出的四行内容全都是写给客户看的。销售团队、支持团队、客户成功团队同样也需要知道到底发布了什么，而且通常需要一种跟这四种完全不一样的形式；&lt;a href=&quot;https://changeloop.dev/blog/zh/internal-release-notes/&quot;&gt;对内发布说明&lt;/a&gt; 讲解了这份文档到底该说些什么，以及它为什么必须比对外说明更早发出去。&lt;/p&gt;
&lt;p&gt;邮件只是上线公告可以使用的多个渠道之一，并不是唯一的选择。&lt;a href=&quot;https://changeloop.dev/blog/zh/new-feature-announcement/&quot;&gt;该怎样发布一条新功能公告&lt;/a&gt;讲解了其他几种渠道，以及该根据这个功能实际的大小，在它们之间做出选择。&lt;/p&gt;
&lt;h2&gt;这份模板到底应该包含什么内容&lt;/h2&gt;
&lt;p&gt;按照这个顺序，一共六个模块。第一个模块，恰恰是通常最容易被遗漏的那一个，也正是真正在做事的那一个。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;主题：  &amp;lt;到底发生了什么变化，用读者自己的语言来表述&amp;gt;

1. 你为什么会收到这封邮件
   &amp;quot;您在三月份曾经请求过 CSV 导出功能。&amp;quot;或者
   &amp;quot;您的集成正在调用 /v1/invoices，而这个接口将在 1 月 15
   日发生变化。&amp;quot;

2. 到底发生了什么变化
   一句话说清楚。现在多了什么新的可能性，或者现在到底
   坏了什么。

3. 你需要做什么
   多数情况下是&amp;quot;什么都不需要做&amp;quot;。请明确地把这一点说出来，
   而不是让它只是隐含在字里行间。

4. 在哪里可以看到
   一个指向体验日志记录的链接，而不是指向主页的链接。

5. 什么时候
   发布上线的具体日期，或者从什么时候开始生效。

6. 怎样取消订阅
   一次点击，并且立刻得到尊重。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一个模块，正是一条真正的消息和一次泛泛的群发通知之间的区别。一个在第一句话里就被告知，这正是自己曾经亲自提出过的那个请求终于得到解决的读者，才会继续往下读。少了这一步，第二到第五个模块，无论文字写得多好，本质上都只是一份普通的新闻通讯而已。&lt;/p&gt;
&lt;p&gt;请把整封邮件的篇幅控制在大约 150 个词以内。这封邮件本身只是一个指向那条体验日志记录的指针，而真正的细节，应该留在那条记录里。一封把整条记录原样复制过来的邮件，既不会给读者留下点击的理由，也不会给你留下任何信号，让你知道到底有没有人真正在乎这件事。&lt;/p&gt;
&lt;h2&gt;什么样的标题真正有效&lt;/h2&gt;
&lt;p&gt;请直接点出具体的变更内容，而不是笼统地提及这一次发布本身。&amp;quot;CSV 导出功能已经上线&amp;quot;要比&amp;quot;九月更新&amp;quot;更有效，因为前者是一个读者可以自行判断的具体事实，而后者只是一个空洞的容器。标题里的版本号，对某个 API 的调用方来说是有用的，但对其他所有人来说都只是噪音，这也是需要把受众区分开来的另一个理由。&lt;/p&gt;
&lt;p&gt;请避免去主张一个读者根本没有同意过的好处。&amp;quot;您的报表现在变得更快了&amp;quot;这种说法，其实是在替读者的体验下结论；而&amp;quot;超过 10,000 行的报表现在会在一秒之内加载完成&amp;quot;这种说法，只是在客观地报告一个变更，然后把这件事到底重不重要的判断权，留给读者自己去决定。&lt;/p&gt;
&lt;h2&gt;应该在什么时候、发给谁&lt;/h2&gt;
&lt;p&gt;请在那件事情正式上线的那一刻，把定向通知逐一发给那些提出过这个请求的人。请在日期一旦确定下来之后就立即发出破坏性变更通知，并在临近那个日期时再发一次，而且只发给真正会受到影响的那些账户，而不是发给整份名单。只有当你积累了足够多的变更，多到读者如果不看摘要就真的会错过什么的时候，才应该发送摘要邮件，并且要让用户能够单独去订阅它。&lt;/p&gt;
&lt;p&gt;那份你几乎永远都不应该使用的名单，就是&amp;quot;全体用户&amp;quot;。它会把一条本来非常具体的消息，硬生生变成一条泛泛而谈的消息，并且会训练用户去取消订阅。请按照你已经在记录的那些行为特征来划分受众：谁提出过这个请求、谁正在使用这个接口、谁正处于哪一个具体的方案之中。&lt;/p&gt;
&lt;h2&gt;发送这封邮件到底需不需要事先取得同意&lt;/h2&gt;
&lt;p&gt;对于现有客户来说，一条关于他们正在使用的某项服务的更新，通常和面向潜在客户的市场营销邮件，在法律层面上完全是两码事，而具体的答案，取决于这些用户身处何地，以及你在他们注册的时候到底告诉过他们什么。在欧盟，真正相关的问题是&lt;a href=&quot;https://gdpr-info.eu/art-6-gdpr/&quot;&gt;《GDPR》第 6 条&lt;/a&gt;当中的哪一项法律依据可以适用；而在美国，商业性质的消息则需要满足&lt;a href=&quot;https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business&quot;&gt;FTC 的 CAN-SPAM 合规指南&lt;/a&gt;中所规定的一系列具体要求。在实际操作层面，这两者的要求其实是一致的：说清楚你到底是谁，把目的讲明白，并且要让用户真正能够停止接收这些邮件。&lt;/p&gt;
&lt;p&gt;无论具体依据是什么，都请在发送这个层面上，把事务性的邮件流和市场营销类的邮件流彻底分开。如果一位客户是因为一封破坏性变更通知和一份促销性质的摘要邮件共用了同一份名单而选择了取消订阅，那么这本身就已经是一个正在等待着自己爆发日期的客服事故了。&lt;/p&gt;
&lt;h2&gt;填好之后到底会是什么样子&lt;/h2&gt;
&lt;p&gt;定向通知，是价值最高的一种产品更新邮件，也恰恰是大多数团队从来都没有真正构建过的那一种。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;主题：CSV 导出功能已经上线

Dana 你好，

你在三月份曾经请求过 CSV 导出功能。

它已经在今天早上正式上线了。报表功能现在多了一个导出
按钮，可以生成当前视图对应的 CSV 文件，筛选条件也会
一并包含在内。

你这边完全不需要做任何事情。这个功能已经在你的账户
上自动启用了。

  详情：example.com/changelog#csv-export
  发布日期：2026 年 9 月 2 日

你之所以会收到这封邮件，是因为你曾经提出过这个请求。
取消订阅请求相关的更新通知：&amp;lt;链接&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一共只有九十个词，而读者在第一句话里，就已经知道了这封邮件为什么会出现在自己的收件箱里。请把这个例子，和同一个变更出现在月度摘要邮件里的情形做个对比：在那种情况下，它只会是九个条目当中的一条，Dana 根本没有任何理由去注意到，那正是她自己曾经提出过的那个请求。&lt;/p&gt;
&lt;h2&gt;到底应该衡量什么&lt;/h2&gt;
&lt;p&gt;不能只看打开率这一个指标。对于定向通知来说，真正要问的问题，是那个提出请求的人到底有没有真正回来使用那项功能，所以真正应该去追踪的数字，是点击进入那条记录的次数，以及这个账户在一周之内到底有没有真正用上那项功能。对于破坏性变更通知来说，真正要看的则是覆盖率：受影响的账户里，到底有多大比例在截止日期之前就已经打开了这封邮件，以及你到底和其中哪些账户进行过一对一的跟进。&lt;/p&gt;
&lt;p&gt;在这四种类型里，摘要邮件是唯一一种打开率本身就具有较高参考意义的类型，而即便如此，把它当作对照自身历史趋势的一个指标，也要比拿它去对照某个行业基准值更有用得多。不同类型的产品更新邮件承担着完全不同的任务，所以把它们全部平均成一个数字，最终什么都说明不了，也没有任何可以据此采取行动的价值。&lt;/p&gt;
&lt;h2&gt;它和发布说明到底有什么不同&lt;/h2&gt;
&lt;p&gt;发布说明是一份会持续保持可用状态的文档。而邮件，则是一种只会发生一次的传递机制。同一个变更往往会同时催生出这两样东西，而邮件本身应该比它所指向的那条记录更短。&lt;a href=&quot;https://changeloop.dev/blog/zh/release-notes-best-practices/&quot;&gt;发布说明的最佳实践&lt;/a&gt;一文讨论了这份文档本身，而&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明的对比&lt;/a&gt;一文，则讨论了你到底正在写的是这两者中的哪一个。&lt;/p&gt;
&lt;p&gt;真正值得花心思去处理好的，是这样一种关系：体验日志里的那条记录，才是那份最权威的文本，而邮件只是在引用它。一旦这两者出现了偏差，那个点击进去查看的读者，就会发现两处对这次变更的描述并不一致，然后就会同时对两者都失去信任。先发布那条记录，再从它那里去生成邮件，这种做法从结构上就直接消除了出现偏差的可能性。changeloop 在自己这一侧也是同样的做法：一条记录会被审核一次，然后同时发布到&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;页面、信息流和小组件&lt;/a&gt;上，而通过小组件提出这个请求的人，会在由他的反馈转成的那个 GitHub issue 上、以及小组件本身里收到通知。changeloop 并不发送邮件；由你的邮件工具去引用那条已发布的记录。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;产品更新邮件到底应该多久发送一次?&lt;/strong&gt;
只要收件人真的有一件具体的事情想要知道，就应该发送，而对于定向通知来说，这意味着他所提出的那个请求每次上线时都应该发送一次；对于摘要邮件来说，则意味着最多每月发送一次。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这封邮件应该包含整条体验日志记录吗?&lt;/strong&gt;
不应该。只需要一句话加上一个链接就够了。那条记录才是最权威的版本，而在邮件里放一份完整的复制内容，只会意味着你需要同时维护两份必须保持一致的文本。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;应该期待一个什么样的打开率?&lt;/strong&gt;
请拿每一种类型自己的历史数据去和它自身做比较，而不是去和某个外部基准值做比较。定向通知和月度摘要邮件本质上是两种完全不同的产品，把它们平均在一起，只会把那个真正值得被追踪的数字给掩盖掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;破坏性变更是不是需要一份单独的名单?&lt;/strong&gt;
需要。而且这份名单应该是这样一种名单：用户没有真正理解后果之前，是没办法随随便便就取消订阅的，因为这正是那份会让他们真正付出服务中断代价的名单。&lt;/p&gt;
</content:encoded></item><item><title>如何停用一个 API，又不至于失去它的开发者</title><link>https://changeloop.dev/blog/zh/api-deprecation/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/api-deprecation/</guid><description>停用是一个带日期的承诺。本文说明停用时间线、通知模板、响应头，以及防止停用下线演变成一场事故的那一步关键操作。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;停用一个 API，就是宣布某个东西今天仍然可以正常工作，并将在一个确定的日期停止工作，然后把这个承诺的两个方面都真正兑现。大多数停用都在第二个方面失败：日期悄悄地推迟了，或者日期到了，但那些从未看到通知的调用方，是通过一个错误才发现的。一次停用真正结束，是当每一个受影响的调用方要么已经完成迁移，要么已经被单独告知他们还没有完成的时候。&lt;/p&gt;
&lt;h2&gt;什么是 API 停用&lt;/h2&gt;
&lt;p&gt;停用，是宣布一个端点、字段或版本即将消失、到真正把它移除之间的这段时间。在这段时间里，旧的行为依然能正常工作，文档说明它即将消失，每一个响应都带有机器可读的警告。移除是一个独立的、更晚发生的事件，通常被称为&amp;quot;服务下线&amp;quot;。这两者经常被混为一谈，而正是这种混淆造成了伤害：&amp;quot;已停用&amp;quot;开始意味着&amp;quot;可能已经消失了&amp;quot;，调用方对这两个词都不再信任。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;术语&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;调用方可以依赖的东西&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;已停用&lt;/td&gt;
&lt;td&gt;已宣布即将消失，但仍然工作&lt;/td&gt;
&lt;td&gt;在服务下线日期之前，行为完全不变&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;服务下线&lt;/td&gt;
&lt;td&gt;停止工作的那个日期&lt;/td&gt;
&lt;td&gt;这个日期之后什么都没有了&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;已退役 / 已移除&lt;/td&gt;
&lt;td&gt;已经消失；请求会失败&lt;/td&gt;
&lt;td&gt;一个错误，理想情况下会指明替代方案&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;遗留&lt;/td&gt;
&lt;td&gt;未定义。避免使用这个词&lt;/td&gt;
&lt;td&gt;什么都没有，而这正是问题所在&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;停用期应该持续多久&lt;/h2&gt;
&lt;p&gt;要足够长，长到让调用方能够发现并完成迁移工作，而且要从通知真正送达他们的那一刻算起，而不是从你写下这条通知的那一刻算起。九十天是公开 web API 的一个常见下限。对于嵌入在终端用户自行安装的软件中的东西，十二个月才是正常的，因为修复也必须通过用户自己的发布流程才能到达。Google 的版本控制指南 &lt;a href=&quot;https://google.aip.dev/185&quot;&gt;AIP-185&lt;/a&gt; 要求提供合理的过渡期，甚至在移除 beta 功能之前也建议留出 180 天，而 Kubernetes 把它的&lt;a href=&quot;https://kubernetes.io/docs/reference/using-api/deprecation-policy/&quot;&gt;停用政策&lt;/a&gt;以发布次数而不是月份来记录，这在调用方按版本升级时才是正确的单位。&lt;/p&gt;
&lt;p&gt;选定一个期限，把它写成一份政策，然后不要再逐个变更去重新决定它。一份已经发布的政策，能把每一次停用都从一场谈判，变成对一条规则的应用。&lt;/p&gt;
&lt;p&gt;把停用政策写下来，覆盖的是窗口期的开始；&lt;a href=&quot;https://changeloop.dev/blog/zh/sunsetting-api-version/&quot;&gt;下线一个 API 版本&lt;/a&gt;
讲的是在结尾处需要的那条独立通知，也就是停用期真正结束、版本真正停止工作的那一刻。&lt;/p&gt;
&lt;h2&gt;停用的时间线&lt;/h2&gt;
&lt;p&gt;四个日期，在第一天就一起宣布出来。它们到来时各自会成为一条独立的体验日志条目，因此对于只读体验日志的人来说，这个故事会被讲述四遍。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;宣布。&lt;/strong&gt; 这条条目说明什么被停用了、为什么、什么将取代它，以及服务下线的日期。旧功能的文档会加上一条链接到迁移方法的横幅。响应会加上下文描述的那些标头。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在中间点提醒一次。&lt;/strong&gt; 第二条条目，以及给每一个仍在使用旧行为的调用方发送的直接消息。这一步需要使用数据：如果你无法列出还有谁在调用那个已停用的端点，你就做不到这一步，而这一点值得在下一次停用之前先修好。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在日期临近之前短暂中断一次。&lt;/strong&gt; 在一个短暂的窗口——一个小时或一天——里对旧行为返回错误，然后恢复它。所有错过了每一条通知的调用方，现在能趁着还有时间发现这件事。GitHub 在&lt;a href=&quot;https://github.blog/2020-07-30-token-authentication-requirements-for-api-and-git-operations/&quot;&gt;停用 API 的密码认证&lt;/a&gt;之前就使用了这种预定的短暂中断，这是这份清单里最有效的一步。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;服务下线。&lt;/strong&gt; 移除它。取代它的那个错误会指明替代方案，并链接到迁移指南。让这个错误长期保留下去；一个 404 什么都不会告诉调用方。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;一条停用通知应该说些什么&lt;/h2&gt;
&lt;p&gt;一条停用通知，应该说明什么即将消失、什么时候停止、应该改用什么，以及谁会受到影响。以下是这个形式的具体示例：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;GET /v1/reports/daily&lt;/code&gt; 已停用，将于 2027 年 3 月 1 日停止工作。&lt;/strong&gt;
它将由 &lt;code&gt;GET /v2/reports?granularity=day&lt;/code&gt; 取代，后者以稳定的 schema 和分页返回相同的数据。影响过去 30 天内调用过 v1 端点的 214 个集成；如果你的集成是其中之一，你也会通过邮件收到这条通知。迁移指南：[链接]。在 2027 年 3 月 1 日之前，一切都不会改变。从那个日期起，v1 端点将返回带有指向本条目链接的 &lt;code&gt;410 Gone&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;每一句话都承载着读者需要的信息。受影响集成的数量，告诉每一位读者是否需要继续往下读。&amp;quot;在……之前，一切都不会改变&amp;quot;这句话，是让不受影响的人可以直接关掉标签页的那一句。&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;页面收集了那些始终坚持写出这种形式的团队的条目，在写自己的第一条之前，值得先读上三条。&lt;/p&gt;
&lt;h2&gt;一个已停用的端点应该发送哪些响应头&lt;/h2&gt;
&lt;p&gt;从宣布之日起，就在这个已停用端点的每一个响应中发送 &lt;code&gt;Deprecation&lt;/code&gt;、&lt;code&gt;Sunset&lt;/code&gt;，以及一个指向后继版本的 &lt;code&gt;Link&lt;/code&gt;。&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc9745&quot;&gt;&lt;code&gt;Deprecation&lt;/code&gt; 标头&lt;/a&gt;携带停用生效的日期；&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8594&quot;&gt;&lt;code&gt;Sunset&lt;/code&gt; 标头&lt;/a&gt;携带该端点停止响应的日期；&lt;code&gt;Link: &amp;lt;url&amp;gt;; rel=&amp;quot;successor-version&amp;quot;&lt;/code&gt; 指明应该改用什么。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 200 OK
Deprecation: @1756425600
Sunset: Mon, 01 Mar 2027 00:00:00 GMT
Link: &amp;lt;https://api.example.com/v2/reports&amp;gt;; rel=&amp;quot;successor-version&amp;quot;
Link: &amp;lt;https://example.com/changelog/daily-reports&amp;gt;; rel=&amp;quot;deprecation&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大多数调用方永远不会自己去读这些标头。它们真正的价值在于，调用方的 HTTP 客户端、网关或监控系统能够读到它们，这会把你的停用变成对方那一侧的一个警报，而不是你这一侧的一个页面。你发布的 SDK，在看到这些标头时应该记录一条警告日志。&lt;/p&gt;
&lt;h2&gt;谁被告知了，你又是怎么知道的&lt;/h2&gt;
&lt;p&gt;这一步决定了服务下线到底会悄无声息，还是会变成一场支持事故，而这也是仅靠体验日志最难做到的一步。一条体验日志条目会告诉所有读体验日志的人。而一次停用必须触达那些代码即将出问题的具体的人，找到他们的常规方式，正是中间点提醒所需要的那份使用数据：最近调用过那个已停用行为的 API key、应用或账户。&lt;/p&gt;
&lt;p&gt;我们运行的这套循环是这样的：这条条目从添加停用逻辑的那个 pull request 起草而来，由人来审核措辞和日期，一旦它被发布，这条条目本身就是通知。任何人，只要其关于那个问题的小组件反馈、或对替代方案的请求，变成了一个由该 pull request 关闭的 GitHub issue，都会在那个 issue 上收到一条评论，说明它已经上线，并附上这条条目的链接。&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;体验日志信息流和小组件&lt;/a&gt;会把同一条条目提供给其他所有人，连同&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志&lt;/a&gt;里的每一条其他记录一起。我们不会做的一件事，是在人真正发布它之前，就让这次停用变成&amp;quot;已上线&amp;quot;；一条日期错误的通知，比完全没有通知更糟糕。&lt;/p&gt;
&lt;p&gt;无论你用什么工具，在服务下线那天你必须能够回答的问题是：上周谁还在使用这个功能，我们又直接告诉了其中的哪些人？如果答案是&amp;quot;我们发过一篇公告&amp;quot;，那么这次服务下线还没准备好。&lt;/p&gt;
&lt;h2&gt;停用和版本控制有什么区别&lt;/h2&gt;
&lt;p&gt;版本控制是在新的行为存在的同时，让旧的行为继续可用的方式；停用是让旧的行为退役的方式。一个没有为上一个版本制定停用政策的新 API 版本，只是一份要把两个版本都永远运行下去的承诺。没有版本控制的停用，则是一个带延迟的&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;破坏性变更&lt;/a&gt;。两者都需要，而版本控制是其中更容易的那一半。GraphQL 是个值得点名的例外：通常那里根本没有版本号可以升，&lt;a href=&quot;https://changeloop.dev/blog/zh/graphql-schema-deprecation/&quot;&gt;GraphQL 模式停用&lt;/a&gt;讲的正是一份共享的模式如何改用一个指令来让某个字段退役。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;一个已停用的端点应该继续和以前完全一样地工作吗?&lt;/strong&gt;
应该，直到服务下线日期为止。唯一被允许的变化，是新增的标头，以及在临近末期时那个已经提前公告过的、有计划的短暂中断。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个已退役的端点应该返回什么状态码?&lt;/strong&gt;
&lt;code&gt;410 Gone&lt;/code&gt;，正文和一个 &lt;code&gt;Link&lt;/code&gt; 标头指向替代方案和体验日志条目。&lt;code&gt;404&lt;/code&gt; 说的是这个 URL 从未存在过，这既是假的，也没有任何帮助。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;停用期可以缩短吗?&lt;/strong&gt;
只有出于安全原因才可以。如果旧行为存在可被利用的漏洞，就明确说明这一点，缩短这段期限，并直接告知每一个受影响的调用方，而不是仅仅依赖体验日志。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我需要停用一个字段，还是只需要停用整个端点?&lt;/strong&gt;
字段、参数、枚举值、默认值和标头都需要同样的处理，因为它们每一个都可能破坏一个正确的调用方。被移除的字段是最常见的停用类型，也是最常被忽略的一种。&lt;/p&gt;
</content:encoded></item><item><title>面向调用方设计的 API 版本控制最佳实践</title><link>https://changeloop.dev/blog/zh/api-versioning-best-practices/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/api-versioning-best-practices/</guid><description>只对真正破坏兼容性的内容做版本管理，把版本号放在调用方看得见的地方，让旧版本运行到明确的截止日期。本文比较四种常见方案，并以对调用方的要求高低为标准。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;API 版本控制，就是在你改动了一份契约之后，依然让旧的契约继续正常工作的做法，这样调用方就可以按照自己的节奏去迁移，而不是被迫按照你的节奏。这句话里包含了两个真正重要的决定：什么样的改动才算是改变了契约，以及旧的契约应该继续工作多久。至于版本号存放在哪里——这正是大多数版本控制争论所围绕的话题——反而是三者中最不重要、也最容易做对的一个。&lt;/p&gt;
&lt;h2&gt;什么时候应该给 API 做版本控制&lt;/h2&gt;
&lt;p&gt;只有当一个变化会破坏一个正确的调用方时，才需要对 API 做版本控制。增量式的变化——新字段、新端点、新的可选参数——并不需要版本，因为按照旧契约编写的调用方仍然可以正常工作，新能力只是多出来的一部分。&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;破坏性变更&lt;/a&gt;则确实需要版本，因为不这样做的代价是让调用方通过一个错误才发现问题。给每一次发布都加上版本号，包括那些增量式的发布，只会教会调用方把版本当成噪音，他们就会不再阅读那些真正重要的通知。&lt;/p&gt;
&lt;p&gt;实用的测试标准和破坏性变更那篇文章里的一样：如果一个只依赖了文档说明行为的调用方，必须改动点什么才能继续正常工作，这个变化就需要一个版本。如果不是，就在当前版本下发布它，并写一条体验日志条目。&lt;/p&gt;
&lt;h2&gt;应该使用哪种 API 版本控制方案&lt;/h2&gt;
&lt;p&gt;使用调用方最容易看到、也最容易设置的那种方案，对大多数公开 API 来说，那就是 URL 路径中的版本号，或者带日期的版本标头。这四种常见方案之间的差别，与其说在于能力，不如说在于它们分别对调用方提出了什么要求，而这才是选择时正确的依据。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;th&gt;调用方必须做什么&lt;/th&gt;
&lt;th&gt;谁在用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;URL 路径&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v2/invoices&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;迁移时修改 URL&lt;/td&gt;
&lt;td&gt;大多数公开的 REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;版本标头&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-GitHub-Api-Version: 2022-11-28&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;发送一个标头，或接受默认值&lt;/td&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;带日期的账户版本&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Stripe-Version: 2026-08-26&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;按请求或按账户固定一个日期&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查询参数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/invoices?version=2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;附加一个参数&lt;/td&gt;
&lt;td&gt;较老的 API；如今很少被选用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;媒体类型&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Accept: application/vnd.example.v2+json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;协商内容类型&lt;/td&gt;
&lt;td&gt;追求纯粹的人；能真正驾驭它的调用方很少&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;URL 路径&lt;/strong&gt;的可见度最高，灵活性最低。每个调用方只需要看一行日志就能知道自己在用哪个版本，升级版本也只是一次查找替换。代价是整个表面会一起移动：你不可能只改变一个端点的契约，而不为所有端点都发布一个新版本，所以路径版本往往既少见、又规模庞大。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;版本标头&lt;/strong&gt;能保持 URL 稳定，并且能让服务端为什么都不发送的调用方选择一个默认值，这正是 &lt;a href=&quot;https://docs.github.com/en/rest/about-the-rest-api/api-versions&quot;&gt;GitHub 的 REST API 版本&lt;/a&gt;的工作方式：&lt;code&gt;X-GitHub-Api-Version&lt;/code&gt; 里是一个以日期命名的版本，以支持的最旧版本作为默认值，这样不指定版本的调用方也不会被破坏。代价是版本在 URL 里是不可见的，在一个新客户端里很容易被遗忘。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;带日期的账户版本&lt;/strong&gt;是在标头方案的基础上多加了一样东西：版本被存储在账户上，因此不需要发送任何东西，每个请求都会自动携带这个版本。&lt;a href=&quot;https://docs.stripe.com/api/versioning&quot;&gt;Stripe 的版本控制&lt;/a&gt;把每个账户固定在它创建时的那个版本上，而一次请求可以用 &lt;code&gt;Stripe-Version&lt;/code&gt; 来覆盖它。这是对调用方最友好的方案，但也是运行成本最高的方案，因为服务端必须在每一个受支持的版本和当前版本之间做转换。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;查询参数&lt;/strong&gt;和&lt;strong&gt;媒体类型&lt;/strong&gt;都能行得通，但各自都以不同的方式在可见性测试上失败：查询参数在拼接 URL 时很容易被漏掉，而媒体类型版本几乎在调用方用来调试的所有工具里都是不可见的。Stripe 的带日期方案是日期方式中最知名的例子，&lt;a href=&quot;https://changeloop.dev/blog/zh/stripe-api-versioning/&quot;&gt;Stripe 如何为其 API 做版本控制&lt;/a&gt;对它做了详细讲解。&lt;/p&gt;
&lt;h2&gt;实际中该怎样做 API 版本控制&lt;/h2&gt;
&lt;p&gt;在实践中，一个版本就是一组有名字的行为集合，服务端会把每一个请求映射到其中一组上。无论哪种方案来承载这个名字，具体步骤都是一样的。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;用日期或整数来命名版本，而不是用语义化版本号。&lt;/strong&gt; web API 不是一个软件包。调用方无法固定一个 URL 的次版本，所以 &lt;code&gt;v2&lt;/code&gt; 或 &lt;code&gt;2026-08-26&lt;/code&gt; 已经能说清调用方所需要的一切，而&lt;a href=&quot;https://semver.org/&quot;&gt;语义化版本控制&lt;/a&gt;的编号，则会暗示一种这套方案根本无法兑现的兼容性承诺。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让版本远离那些不需要关心它的代码路径。&lt;/strong&gt; 版本应该只在边缘处选择一个转换层，而不应该分叉业务逻辑本身。整套代码库维护两份完整的拷贝，正是一个版本最终变得无人维护的方式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给每个版本都设定一个默认值和一份文档。&lt;/strong&gt; 没有指定版本的调用方，应该得到受支持的最旧版本，而不是最新版本，这样一个没有固定版本的客户端才不会在你发布的当天就出问题。每个版本都应该有一个页面，说明相比上一个版本发生了什么变化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;设定一个支持窗口期，并公开发布它。&lt;/strong&gt; Google 的版本控制指南 &lt;a href=&quot;https://google.aip.dev/185&quot;&gt;AIP-185&lt;/a&gt; 要求提供一个合理且沟通充分的过渡期，并建议即使是 beta 功能也留出 180 天。选定一个窗口期，把它写下来，然后不再按每个版本重新谈判地应用它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;像退役端点一样退役版本。&lt;/strong&gt; 一个超过了窗口期的版本，应该得到和任何&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;已停用的 API&lt;/a&gt;一样的对待：一份公告，每个响应上的一个 &lt;code&gt;Sunset&lt;/code&gt; 标头（&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8594&quot;&gt;RFC 8594&lt;/a&gt;），给仍在使用它的调用方的一次中间点提醒，以及一个真正会被执行的移除日期。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;REST API 中的 v1 和 v2 是什么&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;v1&lt;/code&gt; 和 &lt;code&gt;v2&lt;/code&gt; 是同一个服务端同时支持的两份契约的名字。&lt;code&gt;v2&lt;/code&gt; 之所以存在，是因为 &lt;code&gt;v1&lt;/code&gt; 里的某些东西如果不破坏它的调用方就无法改变，于是这个变化进入了一份新的契约，而旧的那份继续工作。这些数字本身并不意味着 &lt;code&gt;v2&lt;/code&gt; 已经完成，或者 &lt;code&gt;v1&lt;/code&gt; 已经死了；只有当文档这样说时，这两件事才是真的。如果每个季度都冒出一个 &lt;code&gt;v3&lt;/code&gt;，那就是一个信号，说明增量式的变化正在被当作版本来处理，或者这份契约从一开始就没有被设计成能够吸收变化。&lt;/p&gt;
&lt;p&gt;这是一种 URL 路径版本控制的模型，版本号是调用方拨号时用的那个路径段。gRPC 服务通常用另一种
方式解决同一个问题：版本存在于 &lt;code&gt;.proto&lt;/code&gt; 文件本身内部的包名里。&lt;a href=&quot;https://changeloop.dev/blog/zh/grpc-protobuf-api-changes/&quot;&gt;gRPC 与 Protobuf&lt;/a&gt;
讨论了这个区别，以及为什么在那里线上兼容性是由字段编号而不是 URL 的形状来定义的。&lt;/p&gt;
&lt;h2&gt;一次版本变更应该宣布什么&lt;/h2&gt;
&lt;p&gt;一次版本变更应该宣布什么会破坏、谁会受影响、如何迁移，以及上一个版本还会继续工作多久。这条条目的形式，和任何其他破坏性变更条目一样，只是多加了一行说明支持窗口期的话。以下是一个带标头版本控制的 API 的示例：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;API 版本 2026-11-01 现已可用。版本 2025-06-15 支持至 2027 年 11 月 1 日。&lt;/strong&gt;
2026-11-01 中的新变化：&lt;code&gt;GET /invoices&lt;/code&gt; 现在会把 &lt;code&gt;amount&lt;/code&gt; 以最小单位的整数形式返回，而不再是一个小数字符串，并且已停用的 &lt;code&gt;customer_name&lt;/code&gt; 字段已被移除，改用 &lt;code&gt;customer&lt;/code&gt; 对象。影响所有把 &lt;code&gt;amount&lt;/code&gt; 当作字符串解析的 2025-06-15 调用方，这是 2025 年 6 月之前创建的、未固定版本的客户端的默认行为。迁移方法：把 &lt;code&gt;amount&lt;/code&gt; 当作整数解析，并从 &lt;code&gt;customer.name&lt;/code&gt; 中读取名字。准备好后请固定 &lt;code&gt;X-Api-Version: 2026-11-01&lt;/code&gt;。对于未固定版本的调用方，不会有任何变化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;最后这句话，正是让大多数读者可以在此停下阅读的那句话，它应该出现在每一次版本公告里。&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;页面收录了以这种方式做版本控制的 API 的条目，而好的条目和其他条目之间的差别，往往就在于这最后一行。&lt;/p&gt;
&lt;h2&gt;版本变更时，谁会被通知到&lt;/h2&gt;
&lt;p&gt;旧版本上的每一个人，都会被单独通知，其他所有人则通过体验日志。版本变更正是&amp;quot;我们发过公告了&amp;quot;这句话必定会漏掉重要调用方的那种情况：那些两年前固定了一个版本、此后再也没读过发布说明的人。使用数据能回答他们是谁；而通知必须触达他们的代码所在的地方，也就是响应标头，以及给账户所有者的一条消息。&lt;/p&gt;
&lt;p&gt;在我们运行的这套循环里，宣布一次版本变更的条目，是从发布它的那个 pull request 起草而来的，由人来审核，然后发布到&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;信息流和小组件&lt;/a&gt;，一个带版本的客户端可以把它当作 JSON 来读取。任何人，只要其小组件反馈要求过这项改动、或报告过它所修复的那个 bug，并且变成了一个由该 pull request 关闭的 GitHub issue，都会在条目上线时，在那个 issue 上收到通知。这套机制和任何条目都一样；一次版本升级只是风险最高的那一条条目而已。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;每一次 API 变更都应该获得一个新版本吗?&lt;/strong&gt;
不需要。只有破坏性变更才需要。增量式的变化在当前版本下发布，并配一条体验日志条目。给增量式变化加上版本，只会训练调用方去忽略版本。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;URL 版本控制和标头版本控制，哪个更好?&lt;/strong&gt;
URL 版本控制对调用方来说更容易看到，对你来说却更难逐步演进；标头版本控制正好相反。对于拥有大量小型客户端的公开 API，URL 版本控制失败得更少。对于带有转换层的大型 API，带日期的标头能更好地扩展。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同一时间应该支持多少个版本?&lt;/strong&gt;
支持的窗口期允许的越少越好，而且绝不能是无限个。同时存在两三个版本是正常的；超过这个数字，通常就意味着版本没有被及时退役。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;没有指定版本的请求应该得到什么?&lt;/strong&gt;
应该得到受支持的最旧版本，这样现有的、未固定版本的客户端才能继续正常工作，同时附带一个响应标头，告诉它们收到的是哪个版本。&lt;/p&gt;
</content:encoded></item><item><title>破坏性变更：哪些算、哪些不算，以及如何安全发布</title><link>https://changeloop.dev/blog/zh/breaking-changes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/breaking-changes/</guid><description>破坏性变更是指正确的调用方无法承受的变更。本文说明哪些算、哪些不算，如何在 CI 中提前发现，以及如何安全地发布出去。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;破坏性变更，就是一个正确编写的调用方根本无法承受的变更。这个定义很重要，因为大多数关于某个变化&amp;quot;算不算数&amp;quot;的争论，实际上都是关于到底是谁用错了方法的争论。如果一个调用方遵循了你的文档，而你的变更让他们的代码停止工作了，那这个变更就是破坏性的。你原本的意图是什么，跟这个判断毫无关系。&lt;/p&gt;
&lt;p&gt;这就是全部的判断标准。这篇文章剩下的内容都由它推导而来：哪些变更通不过这个标准，哪些能通过，如何在合并之前发现问题，以及一旦确认自己正在发布这样的变更，该怎么做。&lt;/p&gt;
&lt;h2&gt;什么算破坏性变更？&lt;/h2&gt;
&lt;p&gt;要把这个测试标准应用在调用方身上，而不是应用在 diff 上。当一个只依赖了文档说明行为的调用方，必须改动自己的代码、配置或数据才能继续正常工作时，这个变更就是破坏性的。移除一个字段、重命名一个端点、收紧校验规则、改变默认值、改变一个值的类型，都符合这个标准。添加一个可选字段不符合。修复一个 bug 通常也不符合，但下面有一个重要的例外。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变更&lt;/th&gt;
&lt;th&gt;是否破坏性？&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;移除或重命名一个字段、端点、开关或选项&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;正确的调用方会引用它&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;添加一个可选字段或一个新端点&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;现有的调用不受影响&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;把一个可选输入变为必填&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;之前省略它的调用现在会失败&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;收紧之前接受的校验规则&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;之前有效的输入现在会被拒绝&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;改变一个默认值&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;没有设置过它的调用方会得到新的行为&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;改变类型（字符串变数字，单个值变数组）&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;按文档类型编写的解析器会失败&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;重新排列一个对象里各个键的顺序&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;除非你文档化过这个顺序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;修复一个调用方依赖过的 bug&lt;/td&gt;
&lt;td&gt;实际上算是&lt;/td&gt;
&lt;td&gt;参见关于偶然契约的那一节&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;提高速率限制或大小上限&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;原本能正常工作的东西不会停止工作&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;降低速率限制或大小上限&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;原本没问题的流量现在会被限流&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;改变一段错误信息的措辞&lt;/td&gt;
&lt;td&gt;视情况而定&lt;/td&gt;
&lt;td&gt;如果你曾经文档化过它，或调用方依据它做匹配，就是破坏性的&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;什么不算破坏性变更？&lt;/h2&gt;
&lt;p&gt;当此前能正常工作的每一个调用依然能原样工作、含义也保持不变时，这个变更就是非破坏性的。添加一个新端点、添加一个可选的请求参数、在响应中添加一个字段、把必填输入改为可选、提高一个限制，以及改进一条没人依据它做匹配的错误信息，都能通过这个测试。这类增量变更可以放在小版本里发布，配上一条普通的体验日志条目即可。&lt;/p&gt;
&lt;p&gt;增量变更在三种情况下仍然会破坏调用方。一个会拒绝未知字段的反序列化器，会在响应出现第一个新字段时失败，所以要尽早在文档里写明，调用方必须忽略自己不认识的字段。一个新的枚举值，会破坏所有写了穷举式 switch 的调用方（下文还会讲到）。而一个变大的响应，可能会让调用方撞上某个他们从来不必考虑的大小限制、超时或列宽。&lt;/p&gt;
&lt;p&gt;表格里有四行值得更仔细地看一看，因为分歧往往就发生在那里。&lt;/p&gt;
&lt;h2&gt;团队最容易忽略的四种破坏性变更&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;偶然形成的契约。&lt;/strong&gt; 如果你的 API 三年来一直返回同一个未在文档中写明的字段，那么一定有调用方在这个字段的基础上构建了自己的逻辑。&lt;a href=&quot;https://www.hyrumslaw.com/&quot;&gt;Hyrum 定律&lt;/a&gt;是这个道理的简短版本：只要用户足够多，你系统里任何一个可以被观察到的行为，最终都会被某个人依赖上。这正是&amp;quot;这本来只是个 bug 修复&amp;quot;站不住脚的原因。这个修复也许是正确的，但仍然可能是破坏性的。把它当作破坏性变更来发布。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;没有伴随 schema 变化的行为变化。&lt;/strong&gt; 字段还在那里，类型也没变，但这个值现在代表了不同的含义。一个原本只会是 &lt;code&gt;active&lt;/code&gt; 或 &lt;code&gt;inactive&lt;/code&gt; 的 &lt;code&gt;status&lt;/code&gt;，如果现在还会返回 &lt;code&gt;suspended&lt;/code&gt;，就会破坏所有写了穷举式 switch 语句的调用方。一个从本地时间切换到 UTC 的时间戳，会破坏所有没有把文档读上两遍的人。OpenAPI 文件的 diff 里完全看不出这些东西。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;收紧了的校验规则。&lt;/strong&gt; 你开始拒绝没有顶级域名的邮箱地址、拒绝末尾带空格的输入、拒绝长度超过 80 个字符的名字。所有恰好一直在发送这类数据的调用方，现在都会为一个上周还能正常工作的请求收到一个 400 错误。校验规则的变化，是最常见的、以&amp;quot;加固&amp;quot;修复的名义发布出去的破坏性变更。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;改变了的默认值。&lt;/strong&gt; 明确设置过这个值的人不会注意到任何变化。而没有设置过它的人（也就是大多数调用方）会在一行代码都没改的情况下得到新的行为。默认值的变化会破坏你的大多数用户，正是因为他们从来没见过这个设置。&lt;/p&gt;
&lt;h2&gt;如何在破坏性变更上线之前发现它？&lt;/h2&gt;
&lt;p&gt;在 CI 里，把拉取请求上的契约和主分支上的契约做比较，一旦出现破坏性差异就让构建失败。大多数接口格式都有对应的 schema 比较工具，每一种都了解自己所属格式的破坏性规则：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;接口&lt;/th&gt;
&lt;th&gt;工具&lt;/th&gt;
&lt;th&gt;比较的内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;REST（OpenAPI）&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/oasdiff/oasdiff&quot;&gt;oasdiff&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;两份 OpenAPI 规范，并给出破坏性变更报告&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gRPC（Protobuf）&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://buf.build/docs/breaking/&quot;&gt;buf breaking&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.proto&lt;/code&gt; 文件，可按线路格式或源码层面比较&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/kamilkisiela/graphql-inspector&quot;&gt;GraphQL Inspector&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;两份 schema，标出破坏性和危险的变更&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust crate&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/obi1kenobi/cargo-semver-checks&quot;&gt;cargo-semver-checks&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;公开 API 与上一个已发布版本的对比&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript 包&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://api-extractor.com/&quot;&gt;API Extractor&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;一份提交到仓库里的包公开 API 报告&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这些工具能可靠地发现被移除的字段、被重命名的操作和被改变的类型。但上面四类里的前两类，也就是偶然形成的契约和行为变化，它们看不到，因为这两类都不会体现在 schema 里。用工具拦住那些显而易见的，其余的则靠评审时问一句&amp;quot;一个正确的调用方会察觉到这个变化吗？&amp;quot;。同一个 CI 任务，也很适合用来要求提交体验日志条目，具体做法见&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-ci-enforcement/&quot;&gt;在 CI 中强制要求体验日志条目&lt;/a&gt;，而&lt;a href=&quot;https://changeloop.dev/blog/zh/grpc-protobuf-api-changes/&quot;&gt;gRPC 与 Protobuf 的 API 变更&lt;/a&gt;则逐一讲解了线路层面的各种情形。&lt;/p&gt;
&lt;h2&gt;如何在提交信息里标记破坏性变更？&lt;/h2&gt;
&lt;p&gt;在&lt;a href=&quot;https://www.conventionalcommits.org/en/v1.0.0/&quot;&gt;Conventional Commits&lt;/a&gt;中，破坏性变更用冒号前的 &lt;code&gt;!&lt;/code&gt;（&lt;code&gt;feat(api)!: remove the legacy export endpoint&lt;/code&gt;）来标记，或者用一个以 &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; 开头、后面跟着说明的页脚来标记。两种写法都对应一个主版本号。把这个页脚当作体验日志条目的初稿来写，写明谁会受到影响、他们必须做什么。&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;约定式提交与体验日志&lt;/a&gt;一文讲了这套约定能帮你走多远。&lt;/p&gt;
&lt;p&gt;同样的规则也适用于库。在语义化版本控制下，移除一个公开函数、收窄一个参数类型或者改变一个返回值，都对应一个主版本号。但库并不总是遵守它：一项针对 &lt;a href=&quot;https://arxiv.org/abs/2110.07889&quot;&gt;119,879 次 Maven Central 升级的研究&lt;/a&gt;发现，有 16.6% 违反了语义化版本控制，但只有 7.9% 的客户端项目受到了影响，因为这些变更中的大部分触及的是没有任何客户端调用过的代码。破坏与否，要在调用方那里衡量。&lt;/p&gt;
&lt;h2&gt;如何发布一个破坏性变更&lt;/h2&gt;
&lt;p&gt;要公开地、在一个确定的日期、带着一条迁移路径去发布它。下面这些步骤是按顺序排列的，而最后一步正是大多数团队会跳过的那一步：告诉那些受到影响的人，他们一直在等待的事情现在已经发生了。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;判断它到底算不算破坏性变更。&lt;/strong&gt; 使用上面的测试标准，而不是 diff。如果两名工程师意见不一致，那它就是破坏性的；这种分歧本身就证明了，调用方完全有可能合理地依赖过原来的行为。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给它一个版本。&lt;/strong&gt; 在&lt;a href=&quot;https://semver.org/&quot;&gt;语义化版本控制&lt;/a&gt;下，破坏性变更对应一个主版本号。如果你运行的是带日期或带版本号的 API，它就应该进入一个新版本，而旧版本继续工作到一个确定的日期为止。如果你无法进行版本管理，那你发布的就不是一个破坏性变更，而是一次带有体验日志条目的故障。哪种方案来承载版本，正是&lt;a href=&quot;https://changeloop.dev/blog/zh/api-versioning-best-practices/&quot;&gt;API 版本控制最佳实践&lt;/a&gt;这篇文章的主题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在代码合并之前就写好条目。&lt;/strong&gt; 这条条目有一个固定的形式：变化是什么、影响谁、他们必须做什么、截止到什么时候。如果这四项你填不全，说明这个变更还没准备好。&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;正是出于这个原因，把这类条目放在最前面，并用日期而不是版本号来标注。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给出一个截止日期，而不是一个发布版本号。&lt;/strong&gt; &amp;quot;在 v5 中移除&amp;quot;对不追踪你发布节奏的人毫无意义。&amp;quot;2026 年 11 月 1 日起停止工作&amp;quot;对每个人来说都是同一个意思。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;提供迁移方案。&lt;/strong&gt; 把旧的调用示例代码放在新的旁边。如果这个变更是重命名，就在同一句话里说出新旧两个名字。如果是一个被移除的字段，就说明那份数据去了哪里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在旧行为曾经被文档化过的每一个地方都发布公告。&lt;/strong&gt; 体验日志、描述该端点的文档页面、SDK 的发布说明，以及响应中的停用标头（如果有的话）。只在一个地方发布公告，等于只告诉了那些恰好看到那个地方的人。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;闭合这个循环。&lt;/strong&gt; 如果有客户要求过这个变更，或者报告过导致它的那个 bug，就在它上线时告诉他们。这一步能把一件&amp;quot;施加在用户身上的事情&amp;quot;，变成一件&amp;quot;和用户一起完成的事情&amp;quot;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;一条好的破坏性变更条目应该是什么样的&lt;/h2&gt;
&lt;p&gt;一条好的条目，会在第一行就点明受影响的调用方，写明日期，并包含修复方法。以下是我们使用的形式，针对收紧校验规则这种情形写的例子：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;没有域名的邮箱地址将从 2026 年 11 月 1 日起被拒绝。&lt;/strong&gt;
&lt;code&gt;POST /users&lt;/code&gt; 和 &lt;code&gt;PATCH /users/:id&lt;/code&gt; 目前接受像 &lt;code&gt;alice@localhost&lt;/code&gt; 这样的 &lt;code&gt;email&lt;/code&gt; 值。从 11 月 1 日起，这些请求将返回 &lt;code&gt;400 invalid_email&lt;/code&gt;。影响所有从内部目录创建用户的集成。迁移方法：发送一个完整的合法地址，或者省略该字段，之后再设置。如果你的地址已经带有域名，就无需任何改动，这一点适用于今年创建账户中的 99.4%。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这类通知到底应该放在哪里，以及旁边还应该放上什么，&lt;a href=&quot;https://changeloop.dev/blog/zh/api-changelog/&quot;&gt;API 体验日志&lt;/a&gt;一文有详细说明。&lt;/p&gt;
&lt;p&gt;结尾的这个百分比不是装饰。它告诉读者到底该不该担心，而这正是他们打开这条条目时想问的问题。&lt;/p&gt;
&lt;h2&gt;为什么不干脆一直避免破坏性变更&lt;/h2&gt;
&lt;p&gt;因为那样做的代价更糟。一个从不破坏任何东西的 API，会不断积累它曾经犯过的每一个错误：命名错误的字段、错误的默认值、本地时间的时间戳。每一个都会永远地向每一个新调用方征税，只为了保护那些原本花一个下午就能完成迁移的调用方。那些以稳定性著称的团队，很少破坏东西，而当他们这样做时，一定是按计划进行的，带着一条迁移路径，以及一条真正送达了目标受众的警示。&lt;/p&gt;
&lt;p&gt;那份警示背后的机制，是姊妹篇文章&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;停用一个 API&lt;/a&gt;的主题。宣布它的那条条目，会以和&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;体验日志信息流&lt;/a&gt;里其他任何一条条目相同的方式起草：来自已经合并的 pull request，为人工审核而保留，然后发布到受影响的调用方已经在阅读的地方。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;破坏性变更和非破坏性变更有什么区别?&lt;/strong&gt;
破坏性变更会迫使一个正确的调用方修改自己的代码、配置或数据才能继续工作。非破坏性变更则让每一个现有的调用都保持工作且含义不变，这就是为什么新增通常是安全的，而移除、重命名和收紧规则通常不安全。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;添加一个必填字段算破坏性变更吗?&lt;/strong&gt;
算。每一个现有的调用都缺少这个字段，所以现在每一个现有的调用都会失败。要么把它添加为带有合理默认值的可选字段，要么给这个端点加上版本。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一次 bug 修复算破坏性变更吗?&lt;/strong&gt;
有可能算。如果调用方依赖了那个有 bug 的行为，修复它就会破坏他们，无论文档写了什么。把任何改变了可观察输出的修复都当作破坏性变更来处理，除非你能证明没有人依赖过它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;语义化版本控制适用于 web API 吗?&lt;/strong&gt;
这条规则本身适用：破坏性变更会得到一个新的主版本号，旧版本会在一段声明过的时间内继续工作。这个编号往往体现在 URL 或日期标头里，而不是一个包版本号。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提前多久通知才算足够?&lt;/strong&gt;
足够让调用方发现这条通知并完成相应的工作。对公开 API 来说，九十天是一个常见的下限；对那些出货给终端用户、无法远程更新的代码来说，需要更长的时间。&lt;/p&gt;
</content:encoded></item><item><title>从体验日志的一侧去闭合客户反馈循环的方法</title><link>https://changeloop.dev/blog/zh/customer-feedback-loop/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/customer-feedback-loop/</guid><description>当提出请求的人被告知功能已上线，反馈循环才算闭合。本文说明这个四步循环、它容易在哪里断裂，以及体验日志为什么能闭合它。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一个客户反馈循环真正闭合，是在提出反馈的那个人被告知它后来怎么样了的时候。不是在它被记录下来的时候，不是在它被排上优先级的时候，甚至不是在它上线的时候。而是在被告知的那一刻。大多数团队能把前三个步骤做得很好，最后一个却完全不做，然后奇怪为什么发反馈的人渐渐不再发了。&lt;/p&gt;
&lt;p&gt;这篇文章讲的正是最后这一步，以及一个具体的主张：体验日志才是应该用来闭合这个循环的地方，因为它是唯一一个在循环能够被闭合的那个时刻就已经存在的成果物。&lt;/p&gt;
&lt;h2&gt;什么是客户反馈循环&lt;/h2&gt;
&lt;p&gt;客户反馈循环，是从一个用户告诉你某件事，到那个用户得知你对此做了什么，这中间的整条路径。它有四个步骤：收集反馈、决定怎么处理、发布结果、告诉提出请求的人。在第四步发生之前，这个循环都是开着的。一个收集反馈、也发布了修复，却从不告诉任何人的团队，拥有的只是一个收件箱，而不是一个循环。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;步骤&lt;/th&gt;
&lt;th&gt;会发生什么&lt;/th&gt;
&lt;th&gt;通常在哪里断裂&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;收集&lt;/td&gt;
&lt;td&gt;反馈到达：小组件、支持团队、销售、访谈&lt;/td&gt;
&lt;td&gt;什么都不会断；每个团队都会做这一步&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;决定&lt;/td&gt;
&lt;td&gt;被分类、和重复项合并、被接受或被拒绝&lt;/td&gt;
&lt;td&gt;拒绝的结果从来不会被传达出去&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发布&lt;/td&gt;
&lt;td&gt;有人把它做出来，并且上线了&lt;/td&gt;
&lt;td&gt;与请求的关联在合并时就丢失了&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;告知&lt;/td&gt;
&lt;td&gt;请求者得知它已经上线&lt;/td&gt;
&lt;td&gt;被跳过，或者只对声音最大的请求者去做&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这篇文章讲的正是第四行。它之所以断裂，是出于结构性的原因，而不是文化上的原因：等到一个功能真正上线时，引发它的那个请求，已经处在和已发布的东西完全不同的一个系统里，而没有人的工作职责是把这两者连接起来。这个循环要从更早的地方开始，也就是最初是怎样提出请求的；&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-ask-for-customer-feedback/&quot;&gt;如何征集客户反馈&lt;/a&gt;介绍了措辞和时机。&lt;/p&gt;
&lt;h2&gt;为什么反馈循环会一直开着&lt;/h2&gt;
&lt;p&gt;反馈循环之所以一直开着，是因为请求和已发布的变化生活在不同的地方，而它们之间的关联，如果存在的话，也是靠手工建立的。请求在一个反馈工具、一个支持团队的收件箱，或者一份电子表格里。变化在一个 pull request 里。公告则在体验日志或一封邮件里。三个系统，三位负责人，从第三个回到第一个的那条关联，靠的是某个人在几个月后凭记忆想起来到底是谁提出的请求。&lt;/p&gt;
&lt;p&gt;还有第二个原因。告知这一步通常被当成一项营销任务(&amp;quot;宣布这个功能&amp;quot;)，而不是一项支持任务(&amp;quot;回复这个人&amp;quot;)。公告发给所有人，却没有触达任何一个具体的人。三月份要求这个功能的人，读到六月份的公告时——如果他读了的话——会把它当成新闻，而不是一个回复。只有当这条消息是明确针对本人的时候，这个循环才会闭合。&lt;/p&gt;
&lt;h2&gt;为什么要从体验日志一侧去闭合循环&lt;/h2&gt;
&lt;p&gt;因为体验日志的条目，正是那个在恰好正确的时刻存在、包含恰好正确的措辞、由恰好正确的人来撰写的唯一成果物。它在变化上线时才存在，此前并不存在。它用读者的语言说明了什么发生了变化，而这正是请求者需要的那条消息。而且它是由刚刚读过那个 pull request 的人写下来的，这也是原始请求与它之间的关联依然可见的唯一时刻。&lt;/p&gt;
&lt;p&gt;比较一下其他的替代方案。从反馈工具一侧闭合循环，意味着反馈工具必须知道这个功能是什么时候上线的，也就意味着需要有人手工去更新一个状态。从 pull request 一侧闭合，意味着在合并的那一刻——变化还没真正上线的时候——就去告诉客户，而一旦部署被延迟，这就会变成一个带着时间戳的、被打破的承诺。从营销公告一侧闭合，则意味着要等一份公告出来，而大多数已发布的变化，从来都不会有公告。&lt;/p&gt;
&lt;p&gt;体验日志正好处在中间：在合并之后，在发布的那个时刻，措辞也已经完成。&lt;/p&gt;
&lt;h2&gt;循环是如何一步一步闭合的&lt;/h2&gt;
&lt;p&gt;这是我们运行的这套机制。这里把它描述成一份规格说明，而不是一次产品导览，因为每一步都可以靠手工或者其他工具来完成；真正重要的是顺序。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;反馈变成即将修复它的那个仓库里的一个 issue。&lt;/strong&gt; 一次小组件提交会被记录为一个带标签的 GitHub issue（&lt;code&gt;feature-request&lt;/code&gt; 或 &lt;code&gt;bug&lt;/code&gt;、一个优先级，以及 &lt;code&gt;from-widget&lt;/code&gt;），提交者的邮箱地址不会写进 issue 正文。这个 issue 就生活在代码旁边，好让第三步能够找到它。手工提交的 issue，比如用&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-template/&quot;&gt;功能请求模板&lt;/a&gt;填写的那种，不在这条路径上：第五步不会在它上面发评论，所以这个循环需要你自己去闭合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修复引用这个 issue。&lt;/strong&gt; pull request 里写着 &lt;code&gt;Fixes #142&lt;/code&gt;，这是 GitHub 自己的关闭关键词。没有任何新东西要学，这和开发者已经在写的句子完全一样。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;体验日志的条目从已合并的 pull request 起草而来，并携带着这条关联。&lt;/strong&gt; 在合并时，草稿被创建出来，&lt;code&gt;#142&lt;/code&gt; 从 PR 正文中被读取出来，并附加到这份草稿上。这条关联是在成本还很低的时候，由机器从已经存在的数据里建立起来的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个人来审核这条条目。&lt;/strong&gt; 措辞、目标读者、以及它到底应不应该被公开发布。一份被放弃的草稿不会闭合任何东西，这是对的：一次恰好引用了某个 issue 的内部重构，并不是新闻。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一旦获得批准，请求者就会被告知。&lt;/strong&gt; 一条评论会被发布在由他们的反馈转成的那个 issue 上，&amp;quot;Shipped —&amp;quot; 后面跟着这条条目的标题，以及一个指向已发布条目的链接，同时小组件也会向提交者展示同一条已上线的条目。只发一次，绝不发第二次，而且只在有人真正发布了这条条目之后才发。同一条条目也会通过&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;信息流和小组件&lt;/a&gt;传递给所有没有提出请求的人。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;第五步的这个顺序，正是整个设计的核心。在合并时就告诉请求者会更早、也更容易，但这样做出错的频率，几乎会和部署被延迟的频率一样高。Feature flag 甚至会打破这个顺序本身，因为&amp;quot;已批准并发布&amp;quot;完全可能发生在这个功能对请求者的账户来说依然不可见的时候；&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-flags-feature-requests/&quot;&gt;feature flag 和功能请求&lt;/a&gt; 讲解了一旦有 flag 掺和进来，这一步到底需要多做哪一层额外的核查。&lt;/p&gt;
&lt;h2&gt;对客户来说，一个已经闭合的循环是什么样的&lt;/h2&gt;
&lt;p&gt;它看起来就像一条回复。客户通过一个小组件发送了一个请求，然后有一天，小组件把它显示为已上线，还附上了一条用他们的语言来描述这件事的条目链接；在 GitHub 上，issue 也会以评论的形式收到同样的消息。他们没有订阅任何新闻通讯，没有去查看路线图，也没有去搜索体验日志。他们只是被告知了。&lt;/p&gt;
&lt;p&gt;正是这种体验，促成了第二次反馈的发生。人们会向那些真正回应的产品发送反馈。&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;页面收录了那些用户明显会不断带着新请求回来的团队的条目，它们的共同点并不是工具，而是这些条目读起来就像是一条回复。&lt;/p&gt;
&lt;h2&gt;如何衡量一个反馈循环&lt;/h2&gt;
&lt;p&gt;衡量那些至少告知了一位请求者的已发布变化所占的比例，以及从发布到告知之间经过的时间。这是两个数字，只要那条关联存在，衡量起来就很容易；如果不存在，就完全无法衡量。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;闭合率&lt;/strong&gt;：本月发布的体验日志条目里，有多少条链接到了至少一个请求，其中又有多少条真正通知了请求者。如果第二个数字远远低于第一个，说明通知环节出了问题；如果第一个数字本身就很低，说明请求没有从 pull request 中被引用，而修复方法就是在 PR 模板里加上一句话。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;发布到告知的时间&lt;/strong&gt;：从条目上线到请求者被告知之间经过的时间。有了上面这套机制，这个时间是几秒钟。靠手工做，通常是几周，或者永远不会发生，而&amp;quot;永远不会发生&amp;quot;正是那个真正重要的数字。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要用收集到的反馈数量来衡量这个循环。收集是最容易的一步，一个去衡量它的团队会去优化它，而这只会产生更多开着的循环。&lt;/p&gt;
&lt;h2&gt;路线图放在哪里合适&lt;/h2&gt;
&lt;p&gt;公开路线图是一种提前闭合循环的方式：它告诉请求者，在这件事上线之前，他们的请求就已经被听到了。它很有用，但不能替代最后那一步。&amp;quot;计划中&amp;quot;是关于未来的一个承诺；&amp;quot;已发布&amp;quot;则是关于当下的一个事实。从同一批 issue 出发，用每一列一个标签的方式来运行&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;公开路线图&lt;/a&gt;，这样同一个请求就能从计划中移动到已发布，而不需要在任何地方重新录入。移到已发布是一次标签变更（&lt;code&gt;roadmap:shipped&lt;/code&gt;），条目获批时不会有任何东西替你完成，所以要在同一次审核中一并处理。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;客户反馈循环的四个步骤是什么?&lt;/strong&gt;
收集、决定、发布、告知。在第四步发生之前，这个循环都是开着的。许多框架会在中间加上分析和排优先级的步骤；它们只是&amp;quot;决定&amp;quot;这一步的细化，没有一个真正闭合了什么。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;当你拒绝一个请求时，也应该告诉客户吗?&lt;/strong&gt;
应该，而这正是循环里最容易被忽视的那条消息。一句清楚的&amp;quot;我们不会做这件事，理由是这样&amp;quot;能结束等待。沉默只会让循环永远开着，客户则会不停地去查看进展。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;闭合循环和宣布一个功能有什么不同?&lt;/strong&gt;
公告是发给所有人的。闭合循环则是对那些提出过请求的人，通过他们当初提出请求的那个渠道，做出的一次回复。两件事都要做；它们是写给不同读者的不同消息。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果请求者不在 GitHub 上怎么办?&lt;/strong&gt;
大多数都不在，这没关系。小组件会一直向他们展示所提交内容的状态，包括已上线的条目和它的链接，所以除了当初写下反馈的那个页面，他们什么都不需要。issue 上的评论，是给那些能看到仓库的人看的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这个循环在 GitLab 或 Bitbucket 上也能用，而不一定要用 GitHub 吗?&lt;/strong&gt;
小组件和体验日志本身可以，但第五步那条自动评论目前还不行。一个用 GitLab 或 Bitbucket 的团队，依然能收到每一条提交，依然会把它记录成一个 issue，也依然会在小组件里向请求者展示状态，只是把这个循环具体闭合回那个 issue 本身，在这类集成出现之前，需要你自己手动去做。&lt;/p&gt;
</content:encoded></item><item><title>最终能够真正变成体验日志条目的功能请求模板</title><link>https://changeloop.dev/blog/zh/feature-request-template/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/feature-request-template/</guid><description>功能请求要有用，前提是功能上线时还能把它找出来。本文说明模板该长什么样、把请求分流到正确位置的标签有哪些，以及体验日志之后会读取哪些字段。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;功能请求模板，是一份只有四个问题的表单：这个人正在尝试做什么、什么阻碍了他们、他们尝试过什么替代方案，以及事情完成之后他们希望怎样被告知。表单上通常还会出现的其他东西——优先级选择器、工作量估算、商业价值评分——都是为接收请求的团队准备的，而提交请求的人往往会把这些内容填错。&lt;/p&gt;
&lt;p&gt;整洁的请求并不是检验一份模板好坏的正确标准。正确的标准是：六个月后，当这个功能真正上线时，是否有人能够找到这份请求、理解它，并把消息告诉写下它的那个人？大多数模板都是为了收集信息而设计的。这一份，是为循环真正闭合的那一天而设计的。&lt;/p&gt;
&lt;h2&gt;一份功能请求模板应该包含什么&lt;/h2&gt;
&lt;p&gt;它应该包含目标、障碍、变通方案，以及一条回到请求者身边的路径。这四个字段按这个顺序排列，每一个都回答了团队之后会问的一个问题。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;它之后回答的问题&lt;/th&gt;
&lt;th&gt;它出现在表单上的原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;你在尝试做什么？&lt;/td&gt;
&lt;td&gt;我们做出来的功能，是不是他们真正需要的那个？&lt;/td&gt;
&lt;td&gt;目标能比任何具体的方案存活得更久&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;今天是什么阻碍了你？&lt;/td&gt;
&lt;td&gt;&amp;quot;完成&amp;quot;到底应该是什么样子？&lt;/td&gt;
&lt;td&gt;只标出差距，不去规定具体的修复方法&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;你现在改用什么代替？&lt;/td&gt;
&lt;td&gt;这件事到底有多紧急？&lt;/td&gt;
&lt;td&gt;一个痛苦的变通方案比一个优先级选择器更有说服力&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;我们应该怎样告诉你？&lt;/td&gt;
&lt;td&gt;谁会收到&amp;quot;已发布&amp;quot;的那条消息？&lt;/td&gt;
&lt;td&gt;这是大多数模板里遗漏掉的字段&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;刻意不包含的东西：作为必填项的建议解决方案（作为自由文字里的评论可以，但不能作为提问的框架）、一个优先级选择器（每个提交者都会选高），以及任何工作量或价值的估算（这是团队分类之后的工作）。一份要求提供解决方案的模板，收到的是关于按钮的请求；一份要求提供目标的模板，收到的是关于结果的请求，而结果，正是体验日志条目所要讲述的对象。&lt;/p&gt;
&lt;h2&gt;这份模板&lt;/h2&gt;
&lt;p&gt;这是我们使用的 GitHub issue 模板，以表单的形式呈现。把它粘贴进 &lt;code&gt;.github/ISSUE_TEMPLATE/feature_request.yml&lt;/code&gt;，它就会在 New Issue 页面上渲染成一份结构化的表单。通过它提交的请求，会落地成和反馈小组件提交的 issue 拥有相同字段的 issue，这一点在下一节里会很重要。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Feature request
description: What you are trying to do, and what stops you.
labels: [&amp;quot;feature-request&amp;quot;]
body:
  - type: textarea
    id: goal
    attributes:
      label: What are you trying to do?
      description: &amp;gt;-
        The outcome, not the button. &amp;quot;Export a month of invoices as one
        PDF&amp;quot; beats &amp;quot;add a PDF export&amp;quot;.
    validations:
      required: true
  - type: textarea
    id: blocker
    attributes:
      label: What stops you today?
      description: &amp;gt;-
        Where the product runs out. An error, a missing option, a limit.
    validations:
      required: true
  - type: textarea
    id: workaround
    attributes:
      label: What do you do instead?
      description: &amp;gt;-
        The spreadsheet, the script, the manual step. &amp;quot;Nothing, I gave
        up&amp;quot; is a valid answer.
  - type: input
    id: contact
    attributes:
      label: How should we tell you when it ships?
      description: &amp;gt;-
        An email address, or leave blank to be notified only on this
        issue.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个细节在起作用。&lt;code&gt;labels: [&amp;quot;feature-request&amp;quot;]&lt;/code&gt; 意味着这个请求在创建时就已经被分类，而不用等到有人去做分类整理。最后一个字段之所以存在，是因为&amp;quot;我们会通知你&amp;quot;是一个承诺，而一个承诺需要一个地址。&lt;/p&gt;
&lt;h2&gt;一个功能请求应该带有哪些标签&lt;/h2&gt;
&lt;p&gt;一个功能请求应该带有一个说明它是什么的标签、一个说明它有多紧急的标签，以及一个说明它来自哪里的标签。三个标签，三个维度，每一个都由不同的读者来使用。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;标签&lt;/th&gt;
&lt;th&gt;取值&lt;/th&gt;
&lt;th&gt;谁会读它&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;种类&lt;/td&gt;
&lt;td&gt;&lt;code&gt;feature-request&lt;/code&gt;、&lt;code&gt;bug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;决定它进入哪个队列的人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;优先级&lt;/td&gt;
&lt;td&gt;&lt;code&gt;priority:low&lt;/code&gt;、&lt;code&gt;priority:medium&lt;/code&gt;、&lt;code&gt;priority:high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;规划下一个周期的人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;来源&lt;/td&gt;
&lt;td&gt;&lt;code&gt;from-widget&lt;/code&gt;、&lt;code&gt;from-form&lt;/code&gt;、&lt;code&gt;from-support&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;统计请求都来自哪里的人&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;小组件在把一次提交记录为一个 issue 时，会打上前两个维度的标签以及 &lt;code&gt;from-widget&lt;/code&gt;；&lt;code&gt;from-form&lt;/code&gt; 和 &lt;code&gt;from-support&lt;/code&gt; 是为通过其他途径进来的请求准备的建议。小组件的标签是：种类（&lt;code&gt;bug&lt;/code&gt; 还是 &lt;code&gt;feature-request&lt;/code&gt;，仅凭消息内容由一个分类器来判断）、优先级（一份冷静而具体的崩溃报告是高；一个已经被问过的重复问题是低；任何哪怕只是暗示了安全问题的内容，无论措辞如何，都会被判为 &lt;code&gt;bug&lt;/code&gt; 且优先级为高），以及 &lt;code&gt;from-widget&lt;/code&gt;。这同样的三个维度，对通过上述模板手工提交进来的请求也一样有效，而这正是重点所在：不管一个请求从哪里进来，它都是一个请求。&lt;/p&gt;
&lt;p&gt;还有一个惯例：小组件会在把 issue 记录下来之前，先把提交者的邮箱地址从 issue 正文里去掉，因为这个 issue 所在的仓库可能是公开的，然后用一个提交参考号来代替它。这个地址不会进入 issue；提交者直接在小组件里跟进结果。如果你的追踪系统对团队之外的人可见，联系方式字段也应该做同样的处理。&lt;/p&gt;
&lt;h2&gt;一个功能请求是如何变成体验日志条目的&lt;/h2&gt;
&lt;p&gt;一个功能请求变成体验日志条目，是在一个 pull request 关闭了这个 issue、并且从那个 pull request 起草出来的条目回过头链接到它的时候。这个机制依靠的是 GitHub 自己的关闭关键词：一个描述里写着 &lt;code&gt;Fixes #142&lt;/code&gt; 的 PR，会在合并时关闭 142 号 issue。如果你的体验日志条目是从已合并的 pull request 起草的，草稿就能连同这个 issue 编号一起被携带过来，这条条目也就知道是谁提出的请求。&lt;/p&gt;
&lt;p&gt;这正是这份模板要求提供目标、而不是解决方案的原因。当条目被写出来时，目标正是撰写者需要的那句话：&amp;quot;你现在可以把一个月的发票导出为一份 PDF 了&amp;quot;是一条体验日志条目。&amp;quot;新增 PDF 导出&amp;quot;是一条提交信息。从 pull request 起草的&lt;a href=&quot;https://changeloop.dev/changelog-tools&quot;&gt;体验日志工具&lt;/a&gt;可以完成收集和关联，但措辞依然需要一个人来完成，而这个人需要目标。&lt;/p&gt;
&lt;h2&gt;上线之后会发生什么&lt;/h2&gt;
&lt;p&gt;请求者会收到通知，并附带指向该条目的链接。在我们的设置里，对于通过小组件进来的请求，这是自动完成的：一条&amp;quot;Shipped — &amp;lt;条目标题&amp;gt;&amp;quot;的评论，带有指向已发布条目的链接，会在一个人批准了该条目之后被发布到那个 issue 上，同时小组件也会向提交者展示同一条条目。用这份模板手工提交的 issue 不会收到自动评论；请按同样的规则，自己去闭合这个循环。这条评论是在批准的时候发布的，而不是在合并的时候，这是刻意的：一条在事情还没真正上线之前就说它已经上线的评论，是一个带着时间戳的、被打破的承诺。每个请求最多只会被通知一次；对同一条条目再次批准，不会产生第二条评论。&lt;/p&gt;
&lt;p&gt;如果你是手工完成这件事，规则是一样的。不要从 pull request 那一刻就闭合循环。要从已发布的条目那一刻闭合，而且只闭合一次。&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;信息流和小组件&lt;/a&gt;会把同一条条目传递给没有提出请求的所有人，也就是大多数人；而那条评论，是为提出过请求的那个人准备的。&lt;/p&gt;
&lt;h2&gt;为什么大多数功能请求模板会失败&lt;/h2&gt;
&lt;p&gt;它们的设计目的是让分类整理更容易，而它们也确实做到了这一点，代价是牺牲了对请求者来说唯一重要的那个时刻。一份有十二个字段的模板，会收到更少的请求，而它收到的那些请求，来自那些有耐心填完十二个字段的人，而这些人并不等同于真正需要这个功能的人。一份只有四个字段、其中一个是&amp;quot;我们该怎么联系你&amp;quot;的模板，会收到更多的请求，并且能够真正回应每一个请求。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;功能请求模板应该询问优先级吗?&lt;/strong&gt;
不应该。应该改为询问变通方案。&amp;quot;我每周五都导出到一份电子表格里，然后重新手工录入一遍&amp;quot;，比提交者自己选了&amp;quot;高&amp;quot;的一个下拉菜单，能透露出更多关于优先级的信息。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;请求者应该提出解决方案吗?&lt;/strong&gt;
可以，写在自由文本里。但不要把它当作提问的框架。以解决方案的形式写出来的请求，彼此之间更难合并，也更难据此写出一条体验日志条目。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;功能请求应该出现在公开路线图上吗?&lt;/strong&gt;
一旦被纳入计划，就应该。同一个 issue 上的一个标签，会把它放进计划中的那一列，请求者就能看着它移动。&lt;a href=&quot;https://changeloop.dev/blog/zh/public-roadmap/&quot;&gt;公开路线图&lt;/a&gt;这篇文章讲的正是这套机制。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;重复的请求应该怎么处理?&lt;/strong&gt;
把新的请求链接到已存在的那个 issue 上，并标注为低优先级；不要把它关闭。每一个重复的请求，都是又一个需要在上线时被告知的人。借助 Changeloop 的自动评论，只有当 pull request 也点名了他们的 issue（&lt;code&gt;Fixes #142, fixes #187&lt;/code&gt;）时，这个人才会被告知。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这份模板应该放在哪里?&lt;/strong&gt;
放在将会接收 pull request 的那个仓库里，这样关闭关键词才能生效。放在一个独立追踪系统里的请求，必须在合并时手工建立关联，而这一步，正是最容易被省略的那一步。&lt;/p&gt;
</content:encoded></item><item><title>用 issue 追踪系统打造三列式公开路线图</title><link>https://changeloop.dev/blog/zh/public-roadmap/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/public-roadmap/</guid><description>公开路线图归根到底是一份关于未来的承诺，所以要保持精简，直接从已在追踪的 issue 生成，并靠 issue 上的一个标签让项目在各列之间移动。本文说明整套机制。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;公开路线图，是一份你打算构建的东西的清单，发布在客户能够看到的地方。真正在起作用的那个词是&lt;em&gt;打算&lt;/em&gt;：路线图是一系列关于未来的承诺，上面的每一项，要么会被你兑现，要么会被人看出来你没有兑现。这正是发布它的理由，也正是大多数公开路线图会在一个季度之内就变得陈旧过时的原因。能够存活下来的版本很精简，源自你已经在维护的数据，并且在另一端连接到体验日志上，这样一份承诺就能在没有人重新录入它的情况下变成一个事实。&lt;/p&gt;
&lt;h2&gt;公开路线图是用来做什么的&lt;/h2&gt;
&lt;p&gt;公开路线图会告诉一个提出请求的客户，他的请求在上线之前就已经被听到了。这是闭合循环的前半部分：&amp;quot;计划中&amp;quot;回答的是&amp;quot;有没有人读到过这个&amp;quot;，而&amp;quot;构建中&amp;quot;回答的是&amp;quot;这件事是不是真的在发生&amp;quot;。这两者都不能替代最后一步——在上线时告诉请求者——但两者都能减少在此期间不断询问进度的人数。&lt;/p&gt;
&lt;p&gt;它还为团队做了一件事：它强制形成一种公开的承诺，这是已知最廉价的一种疗法，用来对付那种悄悄囤积着四百个没人会去做的项目的积压清单。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;列&lt;/th&gt;
&lt;th&gt;它做出的承诺&lt;/th&gt;
&lt;th&gt;什么会把一个项目移进这一列&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;计划中&lt;/td&gt;
&lt;td&gt;我们打算构建这个&lt;/td&gt;
&lt;td&gt;一个决定，以 issue 上的一个标签形式被记录下来&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;构建中&lt;/td&gt;
&lt;td&gt;现在有人正在做这件事&lt;/td&gt;
&lt;td&gt;issue 上的 &lt;code&gt;roadmap:building&lt;/code&gt; 标签&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;已发布&lt;/td&gt;
&lt;td&gt;它已经上线了&lt;/td&gt;
&lt;td&gt;一个 &lt;code&gt;roadmap:shipped&lt;/code&gt; 标签，或者在带着这个标签时关闭 issue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;固定顺序的三列就足够了。第四列（&amp;quot;考虑中&amp;quot;、&amp;quot;审核中&amp;quot;、&amp;quot;积压清单&amp;quot;）正是好意最终变成一座博物馆的地方，也是客户最先学会忽略的那一列。&lt;/p&gt;
&lt;h2&gt;应该把路线图公开吗&lt;/h2&gt;
&lt;p&gt;如果能让它保持精简和诚实，就公开它；如果替代方案是一份很长的、都是&amp;quot;也许会做&amp;quot;的清单，那就把它保持私有。一份公开路线图的代价，和发布它这个动作本身无关：它上面的每一项，现在都成了一个会有人在支持团队、销售电话和续约谈话里问起的问题。你确实会构建的十个项目是一份资产。你也许会构建的六十个项目，则是六十场关于&amp;quot;为什么没做&amp;quot;的未来对话。&lt;/p&gt;
&lt;p&gt;有两个诚实的、不公开路线图的理由：你的计划变化得比一个季度还快，或者你的竞争对手比你的客户还更仔细地读你的路线图。这两个理由都是真实的，而两者的应对方式都是公开得更少一点，而不是完全不公开：只公开&amp;quot;构建中&amp;quot;，把&amp;quot;计划中&amp;quot;留在内部，依然能告诉请求者他们的 issue 正在推进。&lt;/p&gt;
&lt;h2&gt;如何从 GitHub issue 构建一份公开路线图&lt;/h2&gt;
&lt;p&gt;在你已经在追踪的那些 issue 上，为每一列贴一个标签，然后把带标签的 issue 渲染成路线图。什么都不需要重新录入，路线图也不可能与实际工作脱节，而那个最初作为客户请求出现的 issue，会在各列之间移动，却不需要改变身份。&lt;/p&gt;
&lt;p&gt;我们运行的这套机制是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;每一列一个标签，带一个固定前缀&lt;/strong&gt;：&lt;code&gt;roadmap:planned&lt;/code&gt;、&lt;code&gt;roadmap:building&lt;/code&gt;、&lt;code&gt;roadmap:shipped&lt;/code&gt;。在一个已连接的仓库里，任何带有其中一个标签的 issue 都会出现在那一列。三个标签都没有的 issue，就不在路线图上，而这正是大多数 issue 的情况，这样也是对的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;列是一个有顺序的数组，永远保持同样的顺序。&lt;/strong&gt; 计划中、构建中、已发布。不是一个按名字作为键的映射，所以读者（或者一个小组件）永远不需要去猜测这个顺序。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;当一个 issue 同时带有两个标签时，更靠后的那个胜出。&lt;/strong&gt; 有人会在移除 &lt;code&gt;roadmap:planned&lt;/code&gt; 之前，先添加 &lt;code&gt;roadmap:shipped&lt;/code&gt;；一个由&amp;quot;哪个 webhook 最后到达&amp;quot;来驱动的状态机，会因为事件到达顺序的不同，把这个项目放进不同的列里。仅仅根据标签集合本身来判断，能保证无论事件以什么顺序到达，答案都是一样的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;已发布和其他列一样，是一种标签状态。&lt;/strong&gt; 当 issue 被打上 &lt;code&gt;roadmap:shipped&lt;/code&gt;，或者在带着这个标签时被关闭，卡片就会移动。卡片本身不会链接到体验日志条目；细节写在那条条目里，它是从关闭这个 issue 的 pull request 起草而来的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把它作为数据来提供。&lt;/strong&gt; 路线图是一份带有这三列的 JSON 文档，和体验日志信息流一起、带着相同的缓存标头发布出来，这样一个文档网站、一个小组件，或者一个状态页面，都可以在不需要第二次集成的情况下渲染它。&lt;a href=&quot;https://changeloop.dev/docs&quot;&gt;信息流文档&lt;/a&gt;里有确切的格式。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对维护者来说，贴一个标签是一件很小的事，而它就是整个集成方案的全部。不需要一个要保持同步的看板，不需要登录一个独立的工具，客户提交的那个请求，就是路线图上的那个项目；等它上线时，还是同一个项目。&lt;/p&gt;
&lt;h2&gt;公开路线图不应该包含什么&lt;/h2&gt;
&lt;p&gt;它不应该包含日期、估算，或者任何九个月后被问起时会让你尴尬的东西。日期是最经典的错误：路线图上的一个季度，会变成销售资料里的一份承诺，再变成一张标题为&amp;quot;你说过是 Q3&amp;quot;的工单。列本身已经说得够清楚了。&amp;quot;构建中&amp;quot;本身就已经意味着&amp;quot;快到已经有人在做了&amp;quot;。&lt;/p&gt;
&lt;p&gt;它也不应该包含内部的积压清单。一份有三百个项目的路线图是一个搜索问题，而不是一份承诺，而那个在第 212 个位置找到自己请求的客户，也了解到了一件你原本并不打算告诉他的事情。&lt;/p&gt;
&lt;h2&gt;路线图是怎样和体验日志连接起来的&lt;/h2&gt;
&lt;p&gt;路线图和体验日志从两个侧面描述同一批 issue，一种面向未来，一种面向过去。没有人在一个单独的看板上移动卡片。维护者在自己本来就在处理的那个 issue 上改一下标签，条目会从 pull request 起草，而当一个人批准了这条条目，其小组件反馈变成了这个 issue 的请求者，会在这个 issue 上收到通知。把卡片移到已发布仍然是单独的一步，也就是 &lt;code&gt;roadmap:shipped&lt;/code&gt; 标签，所以要把它纳入同一次审核；批准条目并不会替你完成这一步。&lt;/p&gt;
&lt;p&gt;这正是&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;反馈循环那篇文章&lt;/a&gt;从体验日志一侧所描述的同一个循环；路线图则是客户在这个过程中间所看到的东西。&lt;a href=&quot;https://changeloop.dev/changelog-tools&quot;&gt;体验日志工具&lt;/a&gt;的汇总介绍了哪些产品提供了路线图视图，哪些把它当作一个独立的看板来对待，而这正是决定它能否保持准确的关键差异。&lt;/p&gt;
&lt;h2&gt;一份好的公开路线图应该是什么样的&lt;/h2&gt;
&lt;p&gt;它看起来很简短，上面的每一项都是一个任何人都可以打开的 issue。检验标准是：客户能不能从一个项目找到它背后的讨论，能不能从一个已发布的项目找到那条真正描述了实际变化的条目。一份只有功能名称、却没有任何入口的路线图，只不过是一份宣传册。&lt;/p&gt;
&lt;p&gt;以下是一个具体的示例，作为一个小组件会去获取的 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;columns&amp;quot;: [
    { &amp;quot;column&amp;quot;: &amp;quot;planned&amp;quot;, &amp;quot;hasMore&amp;quot;: false, &amp;quot;items&amp;quot;: [
      { &amp;quot;id&amp;quot;: &amp;quot;6b0c1f...&amp;quot;, &amp;quot;column&amp;quot;: &amp;quot;planned&amp;quot;,
        &amp;quot;publicTitle&amp;quot;: &amp;quot;Saved views on the inbox&amp;quot;,
        &amp;quot;publicDescription&amp;quot;: &amp;quot;Keep a filter you use often and come back to it.&amp;quot;,
        &amp;quot;publishedAt&amp;quot;: &amp;quot;2026-09-16T10:04:11.000Z&amp;quot; }
    ]},
    { &amp;quot;column&amp;quot;: &amp;quot;building&amp;quot;, &amp;quot;hasMore&amp;quot;: false, &amp;quot;items&amp;quot;: [
      { &amp;quot;id&amp;quot;: &amp;quot;71a4e2...&amp;quot;, &amp;quot;column&amp;quot;: &amp;quot;building&amp;quot;,
        &amp;quot;publicTitle&amp;quot;: &amp;quot;Roadmap column in the widget&amp;quot;,
        &amp;quot;publicDescription&amp;quot;: &amp;quot;See what is coming without leaving the page.&amp;quot;,
        &amp;quot;publishedAt&amp;quot;: &amp;quot;2026-09-12T08:20:02.000Z&amp;quot; }
    ]},
    { &amp;quot;column&amp;quot;: &amp;quot;shipped&amp;quot;, &amp;quot;hasMore&amp;quot;: false, &amp;quot;items&amp;quot;: [
      { &amp;quot;id&amp;quot;: &amp;quot;5c9d70...&amp;quot;, &amp;quot;column&amp;quot;: &amp;quot;shipped&amp;quot;,
        &amp;quot;publicTitle&amp;quot;: &amp;quot;Feedback filed as labelled issues&amp;quot;,
        &amp;quot;publicDescription&amp;quot;: &amp;quot;Widget submissions arrive as issues your triage already handles.&amp;quot;,
        &amp;quot;publishedAt&amp;quot;: &amp;quot;2026-09-02T15:41:37.000Z&amp;quot; }
    ]}
  ],
  &amp;quot;enabled&amp;quot;: true,
  &amp;quot;language&amp;quot;: &amp;quot;en&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三列共三个项目，就是一份完全合格的公开路线图。它说明了什么即将到来、什么正在发生、什么已经发生，而且每一行都是可以核实的。从 Now/Next/Later 到结果式，另外五种版式及其示例条目，见&lt;a href=&quot;https://changeloop.dev/blog/zh/product-roadmap-examples/&quot;&gt;产品路线图示例&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;一份公开路线图应该有多少个项目?&lt;/strong&gt;
能站得住脚的、越少越好。对一个小产品来说，所有列加起来不到十个是正常的；&amp;quot;计划中&amp;quot;超过三十个，就是一份披着路线图外衣的积压清单。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;公开路线图应该带日期吗?&lt;/strong&gt;
不应该。列能在不设定截止日期的情况下传达先后顺序。如果客户需要一个日期，那应该是一次对话，而不是路线图上的一个项目。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;客户应该给路线图项目投票吗?&lt;/strong&gt;
投票衡量的是谁出现了，而不是什么才重要。一条评论，说明他们今天正在使用的那个变通方案，比五十张投票更有价值，而且它需要投票者付出一点代价，这正是关键所在。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个被取消的路线图项目会怎样?&lt;/strong&gt;
移除标签，并在 issue 上说明原因。一句公开的&amp;quot;我们不会做这件事&amp;quot;是这个循环的一部分，也是大多数团队从来不会去发送的那条消息。&lt;/p&gt;
</content:encoded></item><item><title>体验日志的自动化，以及它自身所存在的局限</title><link>https://changeloop.dev/blog/zh/changelog-automation/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/changelog-automation/</guid><description>收集、排版和发布可以交给自动化，唯独挑选和措辞必须由人来完成，没有例外。本文说明这条界线划在哪里，以及界线移动时会带来什么后果。</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;体验日志的自动化，在它自动化收集、分类和发布这三步、并在挑选和措辞这两步停下时才真正有效。把整个流程都自动化，你发布的就是一份排过版的 git log；一步都不自动化，体验日志就会在发布前从记忆里临时赶出来。真正有用的问题是应该自动化哪些部分，而不是应该自动化多少。&lt;/p&gt;
&lt;p&gt;体验日志自动化项目会在两个方向之一失败，而且从第一次设计会议起就能预见到。自动化得太少，体验日志就会变成一份&amp;quot;应该有人去更新&amp;quot;的文档，意味着它会被抽到短签的那个人断断续续地更新。自动化得太多，它就会变成一份排过版的 git log：完整、准确，却没人读。&lt;/p&gt;
&lt;h2&gt;体验日志的哪些部分应该自动化&lt;/h2&gt;
&lt;p&gt;四个步骤里的三个。收集和发布完全自动化；分类作为第一遍处理，交由人工复核；挑选和措辞永远不自动化。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;步骤&lt;/th&gt;
&lt;th&gt;是否自动化？&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;收集：从提交、PR、工单中把变化整理成一份清单&lt;/td&gt;
&lt;td&gt;完全自动化&lt;/td&gt;
&lt;td&gt;枯燥，赶工期时容易被跳过，机器能做到完美&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;分类：Added、Fixed、Changed、Deprecated、Removed、Security&lt;/td&gt;
&lt;td&gt;先自动跑一遍，再由人工复核&lt;/td&gt;
&lt;td&gt;仅凭元数据大约能做对 80%，出错的那 20% 恰恰是最重要的条目&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;挑选和措辞：告诉读者什么、怎么说&lt;/td&gt;
&lt;td&gt;永远不自动化&lt;/td&gt;
&lt;td&gt;这是这份成果物全部的价值所在&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发布：页面、信息流、邮件、小组件、Slack&lt;/td&gt;
&lt;td&gt;完全自动化，且来自同一个来源&lt;/td&gt;
&lt;td&gt;大多数手工劳动实际发生的地方&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;收集。&lt;/strong&gt; 把变化从它们发生的地方（提交、PR、工单）取出来，整理成一份清单。把这一步完全自动化。人类不擅长做这件事，它很枯燥，而且是赶工期时最容易被跳过的一步。&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;Conventional commits&lt;/a&gt;或 PR 标签通常就是这一步的原材料。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;分类。&lt;/strong&gt; 判断某个变化到底是 Added、Fixed、Changed、Deprecated、Removed 还是 Security。从提交类型或 PR 标签自动跑出第一遍结果，再让人工去复核修正。仅凭元数据，这里的准确率大约在百分之八十左右，而出错的那百分之二十，恰恰集中在最重要的那些条目上，因为模糊性和重要性是相关的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;挑选和措辞。&lt;/strong&gt; 决定应该告诉读者什么，以及怎么说。&lt;strong&gt;不要把这一步自动化。&lt;/strong&gt; 这是这份成果物全部的价值所在，其余的一切都只是物流工作。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布。&lt;/strong&gt; 把写好的条目送到页面、信息流、邮件、应用内小组件、Slack 频道。完全自动化，并且来自同一个来源。这正是大多数手工劳动实际发生的地方，却几乎没人去统计它。这也是能够告诉当初提出这个变化的人&amp;quot;它已经上线了&amp;quot;的那一步，这正是&lt;a href=&quot;https://changeloop.dev/blog/zh/customer-feedback-loop/&quot;&gt;从体验日志一侧闭合反馈循环&lt;/a&gt;的全部内容。这一步里邮件所承担的那一半，在&lt;a href=&quot;https://changeloop.dev/blog/zh/product-update-email/&quot;&gt;产品更新邮件模板&lt;/a&gt;里有它自己的形式。&lt;/p&gt;
&lt;p&gt;最后这一点值得多想一想。团队往往把体验日志当成一个写作问题，然后把大部分时间花在分发上：把条目复制进邮件工具，为应用内展示重新排版，粘贴进 Slack，更新一个文档页面。写作只需要一个小时。而复制粘贴每次发布都要花一个小时，永远如此，而这正是应该交给机器去做的部分。&lt;/p&gt;
&lt;h2&gt;这条界线移动时会发生什么&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;向上移，你会得到一份 git 大杂烩。&lt;/strong&gt; 完全从提交自动化生成，会把 &lt;code&gt;bump deps&lt;/code&gt;、&lt;code&gt;fix flaky test&lt;/code&gt;、&lt;code&gt;wip&lt;/code&gt;、&lt;code&gt;address review comments&lt;/code&gt; 直接摆到客户面前。所有这样做过的团队，最后都加上了一个过滤器，而这个过滤器其实就是挑选这一步换了个名字重新出现，体验反而更差了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;向下移，你会得到一堆临时赶工的内容。&lt;/strong&gt; 完全靠人工收集，意味着条目是在发布时凭记忆写出来的。这正是 &lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog&lt;/a&gt; 一开篇就在警告的那种模式，它会悄悄地劣化：体验日志看起来一直维护得很好，直到某一周所有人都没时间为止。&lt;/p&gt;
&lt;h2&gt;一条体验日志自动化流水线是什么样的&lt;/h2&gt;
&lt;p&gt;四个步骤，恰好一个人工关卡，放在草稿变成公开内容的那个节点上。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在合并时，从 PR 推导出一条草稿条目：类型来自标签或提交前缀，标题作为初稿，链接回 PR，记录作者。把它放进一个未发布的池子里。&lt;/li&gt;
&lt;li&gt;任何人在任何时候都可以编辑任何草稿，编辑的成本很低。大多数只需要改写一行。&lt;/li&gt;
&lt;li&gt;要切出一次发布，池子里的每一条条目都必须已经被编辑过，或者被明确标记为内部条目。这个关卡就是整个设计的核心。没有它，草稿就会在忙碌的那一周未经编辑地直接发布出去。&lt;/li&gt;
&lt;li&gt;发布是从已发布的集合扇出出去：公开页面、信息流、邮件、小组件、Slack 帖子。一个来源，多种渲染，不需要复制。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;第三步是唯一需要人的地方，一旦草稿质量不错，每次发布大约只需要十分钟。当涉及客户的请求时，草稿也会带着它所关闭的那个 issue，这正是第四步能够告知请求者的原因；&lt;a href=&quot;https://changeloop.dev/blog/zh/feature-request-template/&quot;&gt;功能请求模板&lt;/a&gt;的设计正是为了让这条链接能够存活下来。这一步在更大的发布流程中处于什么位置，是&lt;a href=&quot;https://changeloop.dev/blog/zh/release-management-process/&quot;&gt;发布管理流程&lt;/a&gt;一文的主题。&lt;/p&gt;
&lt;h2&gt;自动化对你的数据有什么要求&lt;/h2&gt;
&lt;p&gt;如果体验日志是一份 Markdown 文件，上面这一切都行不通，因为一份文件如果不重新解析回来，就无法渲染到五个不同的展示面，而解析散文的结果，就是你最终会得到一个只显示了半个标题的小组件。&lt;/p&gt;
&lt;p&gt;条目需要是结构化的：类型、日期、版本或发布标识、目标读者、正文和链接。有了这些，文件、页面、信息流和邮件就都成了同一份数据的不同视图。这个结构性的点，是在选择工具之前唯一值得先做对的事情，因为它是之后无法低成本补救的东西。如果每一个需要一条记录的改动都没有真正生成那条记录，上面这一切都不管用；&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-ci-enforcement/&quot;&gt;在 CI 里强制要求一条体验日志记录&lt;/a&gt;讲的是怎么让流水线拒绝没有记录的合并，而不是把这一步交给人的记性。&lt;/p&gt;
&lt;p&gt;我们在构建 &lt;a href=&quot;https://changeloop.dev/&quot;&gt;changeloop&lt;/a&gt;，在这里体验日志首先是一个信息流，其次才是一个页面，所以请把这段话当作利益相关的立场，而不是中立的推荐来看；&lt;a href=&quot;https://changeloop.dev/pricing&quot;&gt;定价&lt;/a&gt;提供一个无需信用卡的免费仓库，足够看清它的形态。&lt;a href=&quot;https://changeloop.dev/changelog-tools&quot;&gt;体验日志工具&lt;/a&gt;是我们对现有其他选择的汇总，包括我们的竞争对手，&lt;a href=&quot;https://changeloop.dev/changelog-generator&quot;&gt;体验日志生成器&lt;/a&gt;则可以在浏览器里完成收集和分类这两步，如果你想在投入一整条流水线之前先看看推导的效果。&lt;/p&gt;
&lt;h2&gt;这个测试&lt;/h2&gt;
&lt;p&gt;数一数从一个变化被合并，到那个不读你仓库的客户能看见它，中间隔了多少分钟。如果大部分时间都花在有人在不同工具之间复制文字，那么你需要的自动化在发布环节，而不是写作环节。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;AI 能写体验日志吗?&lt;/strong&gt;
它能起草一份。给一个模型一个已经合并的 pull request，大多数情况下它能产出一份可用的标题和正文初稿，这相当于把收集和分类这两步做得更好了。而挑选——即是否应该告诉读者，以及最终的措辞——依然需要一个了解目标读者的人来完成，一条不带这个关卡就发布草稿的流水线，自动化的是错误的那一步。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志生成器和体验日志自动化有什么区别?&lt;/strong&gt;
生成器是在需要的时候，把提交一次性转换成一份排好版的列表。自动化则是在每次合并时运行，维护一个未发布的池子，把发布的条件设为人工审核，并从同一个来源发布到每一个展示面。生成器是这条流水线的第一步，由人手动运行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志应该从提交自动化，还是从 pull request 自动化?&lt;/strong&gt;
应该从 pull request，因为在这里变化的单位就是 PR：标题和描述是针对整个变化写一次的，而 PR 会链接它所关闭的 issue。基于提交的推导，在提交就是变化单位、并且遵循某种约定书写时才有效。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎样阻止自动化发布内部变更?&lt;/strong&gt;
把 &lt;code&gt;chore&lt;/code&gt;、&lt;code&gt;ci&lt;/code&gt;、&lt;code&gt;test&lt;/code&gt;、&lt;code&gt;refactor&lt;/code&gt; 和依赖升级默认分类为内部条目，把提升为公开条目变成一个刻意的动作。反过来的默认设置——默认公开，除非有人把它隐藏起来——正是 &lt;code&gt;bump deps&lt;/code&gt; 会传到客户那里的原因。&lt;/p&gt;
</content:encoded></item><item><title>从 conventional commits 到一份体验日志</title><link>https://changeloop.dev/blog/zh/conventional-commits-changelog/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/conventional-commits-changelog/</guid><description>Conventional commits 能让体验日志内容被自动推导出来，但不会因此自动变得好读。本文说明这个约定带来什么好处、在哪里走到尽头，以及如何弥补两者之间的差距。</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Conventional commits 免费给体验日志带来三样东西：每个变化的类型、它触及的系统部分，以及它是否会破坏什么。除此之外，它什么都不给。措辞、分组和挑选，也就是体验日志真正的内容，完全留给你自己去解决，假装并非如此的流水线，最终只会发布一份排过版的 git log。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;feat(exports): add CSV column selection
fix(auth): reject expired refresh tokens
chore(deps): bump node-pg to 8.11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是 &lt;a href=&quot;https://www.conventionalcommits.org/&quot;&gt;Conventional Commits&lt;/a&gt; 格式的三次提交。从这些提交中，机器能告诉你其中一个是新功能、一个是修复、一个是日常维护，以及每一个各自触及了系统的哪个部分。这确实很有用，也正是这个约定的全部承诺：一份能被人类之外的东西读懂的提交历史。错误在于以为这样就得到了一份体验日志。它给你的其实只是原材料。&lt;/p&gt;
&lt;h2&gt;这个约定规定了什么&lt;/h2&gt;
&lt;p&gt;一个类型、一个可选的范围，以及一段描述：&lt;code&gt;type(scope): description&lt;/code&gt;。类型通常是 &lt;code&gt;feat&lt;/code&gt;、&lt;code&gt;fix&lt;/code&gt;、&lt;code&gt;chore&lt;/code&gt;、&lt;code&gt;docs&lt;/code&gt;、&lt;code&gt;refactor&lt;/code&gt;、&lt;code&gt;test&lt;/code&gt;、&lt;code&gt;perf&lt;/code&gt;、&lt;code&gt;build&lt;/code&gt;、&lt;code&gt;ci&lt;/code&gt;。有两种标记表示破坏性变更：冒号前的 &lt;code&gt;!&lt;/code&gt;，或者一段 &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; 脚注。工具依据 &lt;code&gt;feat&lt;/code&gt; 和 &lt;code&gt;fix&lt;/code&gt; 来决定次版本号和补丁版本号的升级，依据破坏性标记来决定主版本号的升级。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;提交能给你的&lt;/th&gt;
&lt;th&gt;体验日志需要的&lt;/th&gt;
&lt;th&gt;谁来补上这个差距&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feat&lt;/code&gt; / &lt;code&gt;fix&lt;/code&gt; / &lt;code&gt;chore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Added / Fixed / 内部&lt;/td&gt;
&lt;td&gt;一次映射，自动完成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(scope)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;读者能认得出的分组&lt;/td&gt;
&lt;td&gt;一个人，每个范围一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;!&lt;/code&gt; 或 &lt;code&gt;BREAKING CHANGE:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;谁会受影响、什么时候、该怎么做&lt;/td&gt;
&lt;td&gt;一个人，每次都要&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写给审查者看的描述&lt;/td&gt;
&lt;td&gt;写给客户看的结果&lt;/td&gt;
&lt;td&gt;一个人，每条条目都要&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;一次提交&lt;/td&gt;
&lt;td&gt;一个变化，可能对应许多次提交&lt;/td&gt;
&lt;td&gt;压缩合并规则，或者一个人&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这个标记告诉的是工具，而不是调用方，那是&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;如何停用一个 API&lt;/a&gt;和&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是破坏性变更&lt;/a&gt;这两篇文章的主题。这是一份很小的规范，即使你从来不打算从中生成任何东西，遵循它依然值得，因为它强迫每次提交都做出一个决定：这是不是用户能看到的变化。&lt;/p&gt;
&lt;h2&gt;Conventional commits 在哪里停下&lt;/h2&gt;
&lt;p&gt;它止步于这一句话。这个约定所捕捉到的一切，都只是关于一个变化的元数据；变化本身仍然是用审查者的词汇描述的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提交信息是写给审查者看的。&lt;/strong&gt; &lt;code&gt;fix(auth): reject expired refresh tokens&lt;/code&gt; 是准确的，但对客户什么都没说。体验日志的读者想看到的是&amp;quot;当会话真正过期时你会被登出，而不再是偶尔看到 401 错误&amp;quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;范围是内部的。&lt;/strong&gt; &lt;code&gt;exports&lt;/code&gt;、&lt;code&gt;auth&lt;/code&gt;、&lt;code&gt;ingest&lt;/code&gt; 都是模块名。它们很稳定，这让它们适合用来分组，但对代码库之外的任何人都毫无意义。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个变化往往对应好几次提交。&lt;/strong&gt; 一个跨越十一次提交合并进来的功能，会产生十一条条目，其中十条都是噪音，而通过压缩合并来掩盖这一点，会丢失审查历史。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;chore&lt;/code&gt; 是个筐，不是一个分类。&lt;/strong&gt; 依赖升级、CI 变更和重命名都会落进这里，其中有些确实和用户相关，但大多数并不相关。&lt;/p&gt;
&lt;p&gt;所以：这个约定免费给了你类型、范围和是否破坏性的状态，却把措辞、分组和挑选完全留给你自己。这三样，才是体验日志真正的内容。
&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-entry-ownership/&quot;&gt;体验日志的每一条条目，到底应该由谁来负责&lt;/a&gt; 讨论了到底
应该由谁来承担这份措辞、分组和挑选的工作，因为这个约定本身对此完全没有意见。&lt;/p&gt;
&lt;h2&gt;如何从 conventional commits 生成一份体验日志&lt;/h2&gt;
&lt;p&gt;分两层，第二层必须是强制的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一层，自动化。&lt;/strong&gt; 在合并时，从提交推导出一条草稿条目：把类型映射到体验日志的类型（&lt;code&gt;feat&lt;/code&gt; 对应 Added，&lt;code&gt;fix&lt;/code&gt; 对应 Fixed，破坏性标记对应带标记的 Changed），把范围作为元数据保留而不是当作文字，附上指向 PR 的链接。把它放进 &lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog&lt;/a&gt; 所要求的那个 Unreleased 小节里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二层，人工，且必须存在。&lt;/strong&gt; 在发布之前，每一条草稿条目要么被用用户的词汇改写成一行文字，要么被标记为内部条目并从公开视图中移除。这是人们总想跳过的一步，而跳过它正是体验日志读起来像一份 diff 的原因。&lt;/p&gt;
&lt;p&gt;一个重要的设计细节是，第二层在整条流水线里不是可选的。如果一次发布可以带着未经编辑的草稿被切出去，那它就一定会在大家都很忙的那一周被切出去。哪些步骤属于机器，哪些属于人，正是&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;这篇文章的全部内容。&lt;/p&gt;
&lt;p&gt;切出一次发布，同时也是 git 标签、发布本身，和这条体验日志记录彼此对齐、或者开始偏离同步的那个时刻；&lt;a href=&quot;https://changeloop.dev/blog/zh/git-tags-releases-changelog/&quot;&gt;Git 标签、发布记录，和你的体验日志&lt;/a&gt; 讲解了该怎样让这三者保持同步。&lt;/p&gt;
&lt;h2&gt;三个陷阱&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;压缩合并会吃掉脚注。&lt;/strong&gt; 如果你的平台在压缩合并时把 PR 标题当作提交信息，那个分支里某次提交的 &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; 脚注就会消失，你的工具会悄悄地不再看见这次破坏性变更。检查一下你的压缩合并模板到底保留了什么。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;回滚提交会产生幽灵条目。&lt;/strong&gt; 一个第二天就被回滚的 &lt;code&gt;fix&lt;/code&gt;，除非推导过程会去核对回滚记录，否则会为一件从未真正发布过的事情生成一条条目。大多数工具都不会去核对。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;版本号升级和体验日志会失去同步。&lt;/strong&gt; 如果版本号是从提交计算出来的，而体验日志是之后手工写的，两者大约会在两次发布之内就出现偏差。要么在同一个流程里计算这两者，要么就接受其中一个是错的。&lt;/p&gt;
&lt;h2&gt;如果你只想要没有流水线的机械部分&lt;/h2&gt;
&lt;p&gt;我们的&lt;a href=&quot;https://changeloop.dev/changelog-generator&quot;&gt;体验日志生成器&lt;/a&gt;会在浏览器里完成推导这一步：粘贴提交记录，得到分好组、标好类型的条目。它被刻意设计成确定性的，完全在客户端运行，所以你粘贴的提交记录永远不会离开你的机器，这在提交信息来自私有仓库时很重要。它诚实地完成了收集这一半，完全不去尝试第二层，因为第二层是一种判断，而一个假装能完成它的工具，最终生成的正是这篇文章所反对的那种体验日志。&lt;/p&gt;
&lt;p&gt;如果需要完整的流水线版本，&lt;a href=&quot;https://changeloop.dev/changelog-tools&quot;&gt;体验日志工具&lt;/a&gt;汇总了现有的选择。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;Conventional commits 能够可靠且低成本地回答&amp;quot;这是什么类型的变化&amp;quot;。它无法回答&amp;quot;我们应该告诉人们什么&amp;quot;，无论在提交信息之上堆多少工具都无法回答，因为这个信息从来就不存在于提交信息里。为重写这一步预留预算吧。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Conventional commits 会自动生成体验日志吗?&lt;/strong&gt;
它会自动生成草稿：带类型、带范围、带链接的条目。写给客户看的措辞、分组以及决定省略什么，依然需要一个人来完成，跳过这一步的流水线，发布的其实就是提交信息本身。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;哪些 conventional commit 类型会出现在体验日志里?&lt;/strong&gt;
&lt;code&gt;feat&lt;/code&gt; 和 &lt;code&gt;fix&lt;/code&gt; 总会出现，分别对应 Added 和 Fixed。&lt;code&gt;perf&lt;/code&gt; 通常也会出现，对应 Changed。&lt;code&gt;chore&lt;/code&gt;、&lt;code&gt;docs&lt;/code&gt;、&lt;code&gt;refactor&lt;/code&gt;、&lt;code&gt;test&lt;/code&gt;、&lt;code&gt;build&lt;/code&gt; 和 &lt;code&gt;ci&lt;/code&gt; 默认是内部条目，只有在有人把它们提升出来时才会出现。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conventional commits 如何标记破坏性变更?&lt;/strong&gt;
在类型或范围后面加一个 &lt;code&gt;!&lt;/code&gt;（&lt;code&gt;feat(api)!: ...&lt;/code&gt;），或者在提交正文里加一段 &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; 脚注。如果压缩合并只保留了 PR 标题，两者都会丢失。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自动化体验日志一定需要 conventional commits 吗?&lt;/strong&gt;
不需要。对于通过 pull request 合并代码的团队，PR 标签、PR 模板和 issue 链接能传递同样的元数据。当变化的单位是一次提交时，conventional commits 是成本最低的选择。&lt;/p&gt;
</content:encoded></item><item><title>体验日志与发布说明二者之间的区别到底是什么</title><link>https://changeloop.dev/blog/zh/changelog-vs-release-notes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/changelog-vs-release-notes/</guid><description>体验日志是供人查找的持续累积的记录，发布说明则是写给还在犹豫是否关心这次更新的人的一条精选消息。两者面向的读者不同，需要区别对待，本文说明如何划分。</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;体验日志是一份持续累积、记录所有变化的记录，写给那些正在查找某个具体信息的人看。发布说明则是关于某一个版本、经过挑选的一条消息，写给那些还在决定这次更新是否与自己有关的人看。二者的区别在于目标读者，而不是格式，大多数团队其实两者都需要：一份作为参考资料，一份作为公告，都源自同一批条目。&lt;/p&gt;
&lt;p&gt;大多数团队最终会因为一种偶然拥有其中之一，再因为一次请求拥有另一个。你一开始建立体验日志，是因为某个开发者想要一份已发布内容的记录。几个月后，支持团队的某个人会问，为什么客户不知道那个自四月起就已经上线的功能，于是你就需要发布说明了。&lt;/p&gt;
&lt;h2&gt;体验日志与发布说明的并排对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;体验日志&lt;/th&gt;
&lt;th&gt;发布说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;读者&lt;/td&gt;
&lt;td&gt;正在查找某个信息的人&lt;/td&gt;
&lt;td&gt;正在决定是否关心的人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;范围&lt;/td&gt;
&lt;td&gt;所有发生的变化&lt;/td&gt;
&lt;td&gt;关于这次发布值得说的内容&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;频率&lt;/td&gt;
&lt;td&gt;持续更新，每次合并或每次发布&lt;/td&gt;
&lt;td&gt;每次发布一次，只发布值得公告的版本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;语气&lt;/td&gt;
&lt;td&gt;简洁、客观，常用祈使句&lt;/td&gt;
&lt;td&gt;解释性的，有时带说服意味&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;生命周期&lt;/td&gt;
&lt;td&gt;永久保留，多年后仍会被查阅&lt;/td&gt;
&lt;td&gt;第一周被阅读，之后归档&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;存放位置&lt;/td&gt;
&lt;td&gt;代码仓库、文档站点、&lt;code&gt;/changelog&lt;/code&gt; 页面&lt;/td&gt;
&lt;td&gt;邮件、应用内、博客文章、发布页面&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;失败方式&lt;/td&gt;
&lt;td&gt;因为不完整而失败&lt;/td&gt;
&lt;td&gt;因为无趣，或来得太迟而失败&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;什么是体验日志&lt;/h2&gt;
&lt;p&gt;体验日志是按时间顺序、几乎完整地记录变化的清单，最新的排在最前，每条都标注类型（新增、变更、废弃、移除、修复、安全）和日期。它的读者已经决定要关心了，他们正在查找某个信息：某个行为是什么时候变的，某个 bug 有没有修复，哪个版本引入了某个开关。完整性就是它的全部价值，这也是为什么 &lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog&lt;/a&gt; 这个惯例，在短短一页里几乎把全部篇幅都用在了结构上，几乎没有花在措辞上。&lt;/p&gt;
&lt;h2&gt;什么是发布说明&lt;/h2&gt;
&lt;p&gt;发布说明是关于某一个版本、经过挑选、用散文写成的一条消息。它的读者还什么都没决定，他们正在判断这次发布是否与自己有关，以及自己是否需要为此做点什么。挑选就是它的全部价值：一份把所有东西都列出来的发布说明，不过是加了几段话的体验日志，它辜负读者的方式，和一份漏掉重要内容的体验日志辜负读者的方式是一样的。&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;如何写发布说明&lt;/a&gt;一文谈的就是这种挑选和措辞。&lt;/p&gt;
&lt;h2&gt;体验日志和发布说明是不是都需要&lt;/h2&gt;
&lt;p&gt;一旦两个读者群体想要的东西开始不同，你就两者都需要；在那之前，用一份成果物同时承担两个职责是对的。小团队会发布一个单一的 &lt;code&gt;/changelog&lt;/code&gt; 页面，在每条条目的开头加一小段说明，在一段时间里，这样既能服务查找修复信息的开发者，也能服务浏览新闻的客户。拆分得太早，只会让你多出两样东西要维护，其中一样注定会腐坏。&lt;/p&gt;
&lt;p&gt;出现以下情况时，拆分就值得了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你的体验日志条目开始长出开发者会直接跳过的解释性段落。&lt;/li&gt;
&lt;li&gt;或者反过来：你的发布公告里开始列出依赖升级信息。&lt;/li&gt;
&lt;li&gt;支持团队正在把条目复制进邮件，并在途中重写它们。&lt;/li&gt;
&lt;li&gt;有人要求&amp;quot;只看破坏性变更&amp;quot;，而你无法为他们筛选出来。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后一条才是真正的信号。如果不读完全部内容，就没人能回答&amp;quot;哪些变化影响到了我&amp;quot;，那说明一个成果物正在勉强承担两个职责，而且都做得不好。&lt;/p&gt;
&lt;h2&gt;一个来源，两种视图&lt;/h2&gt;
&lt;p&gt;常见的错误是把它们当成两份文档来对待。它们其实是同一批变化的两种视图。&lt;/p&gt;
&lt;p&gt;在开发过程中就写体验日志，每个有意义的变化一条，各自标注它是什么：修复、新增、变更、移除、废弃、安全。让条目保持足够简短，写一条不需要费心决策。然后，在发布时，发布说明就是一次挑选和重写：挑出对人有意义的条目，按照它们能让人做成什么事来分组，把原因放在最前面。&lt;/p&gt;
&lt;p&gt;这带来一个实际的结果。如果体验日志是数据来源，它就需要是结构化的数据，而不是手工维护的页面。一条条目需要有类型、日期、版本，以及说明它是给谁看的方式。一旦具备了这些，公开页面、应用内小组件和 RSS 或 JSON 信息流就都是同一份东西的三种渲染结果，途中没有人需要重写任何内容。发布说明邮件也可以引用同一条条目，无论你用什么工具发送邮件。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;谈的就是这几个步骤中，哪些应该交给机器负责。这就是把体验日志当作一个信息流而不是一个页面来处理的全部理由。而且坦白说，这正是我们在做的产品，所以请把这段话当作利益相关的立场，而不是中立的调研结论来看。&lt;/p&gt;
&lt;h2&gt;如果你只有时间做一件事&lt;/h2&gt;
&lt;p&gt;写体验日志。它每条的成本更低，写下的当天就有用，而发布说明之后可以从它推导出来。反过来则不成立：你没办法从十二封公告邮件里，重新拼出一整年的变化记录，而人们总会向你要这份记录。&lt;/p&gt;
&lt;p&gt;把它维护成固定的格式，这样推导才始终可行。我们的&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;页面收集了那些把体验日志做得很好的团队的条目，&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;则是我们在把一批条目变成值得发送的内容时所用的格式。&lt;/p&gt;
&lt;h2&gt;关于命名的一点说明&lt;/h2&gt;
&lt;p&gt;这方面并没有统一的标准，你会看到有人把&amp;quot;release notes&amp;quot;用来指持续更新的列表，也有人把&amp;quot;changelog&amp;quot;用来指季度公告。为这些词争论并不值得。决定你的每个成果物到底承担哪一种职责，用团队已经在用的名字去称呼它，并确保任何一个都没有在悄悄地同时承担两种职责。&lt;/p&gt;
&lt;p&gt;结果最终落在哪个表现形式上，是一个独立的决定，&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-page/&quot;&gt;如何搭建体验日志页面&lt;/a&gt;一文有详细说明。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;体验日志和发布说明是同一回事吗?&lt;/strong&gt;
不是。体验日志是完整的记录，供正在查找信息的人阅读；发布说明是经过挑选的公告，供正在决定是否关心的人阅读。同一个变化会出现在两者中，但针对不同的读者会用不同的措辞。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明可以从体验日志生成吗?&lt;/strong&gt;
可以，而且这才是正确的方向。挑出人们会关心的条目，按结果分组，重写标题。反过来，从公告去重构体验日志，会丢失公告里省略掉的一切内容。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志应该放在哪里?&lt;/strong&gt;
放在一个永久、可链接、读者无需仓库权限就能访问到的地方：一个 &lt;code&gt;/changelog&lt;/code&gt; 页面、一个文档站点，或者一个可以渲染到多处的信息流。单独一份 &lt;code&gt;CHANGELOG.md&lt;/code&gt; 只能触达贡献者，触达不了客户。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志应该包含内部变更吗?&lt;/strong&gt;
应该，放在最后，每项一行。体验日志是完整的记录。发布说明也可以保留它们，放在末尾一个简短的小节里，只要读者会注意到的那些变更排在前面就行。&lt;/p&gt;
</content:encoded></item><item><title>如何写出用户真正愿意花时间读完的发布说明</title><link>https://changeloop.dev/blog/zh/how-to-write-release-notes/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/how-to-write-release-notes/</guid><description>只写错误修复和性能改进，算不上一条合格的发布说明。本文说明每条发布说明都必须回答的核心问题，并用一个真实案例对比修改前后的两个版本，展示该怎样写。</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;要写出用户真正会读的发布说明，每一条都只需回答一个问题：读者现在能做什么以前做不到的事，以及他们需要为此做什么。把带有截止日期的内容放在最前面，明确说出谁会受到影响，如果确实不需要任何操作就直接说&amp;quot;无需操作&amp;quot;，没有什么可说的版本就跳过不发。这个页面剩下的所有内容，都是这条规则的具体应用。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;错误修复和性能改进。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;每个产品都发过这样的说明。原因很少是懒惰：这正是发布说明由内部人员撰写时会出现的结果——他们已经在 diff 里泡了两个星期，早已看不出一个陌生人到底会关心其中的哪一部分。换一种更好的语气解决不了这个问题，只有回答那个问题才能解决。&lt;/p&gt;
&lt;h2&gt;发布说明应该包含什么&lt;/h2&gt;
&lt;p&gt;对于每一个值得一提的变化，发布说明应该说明读者现在能做什么、谁适用、他们必须做什么（包括&amp;quot;什么都不用做&amp;quot;），以及任何带截止日期的内容何时生效。不应该包含内部工单编号、只有团队自己才懂的组件名称，或者仅以版本号作为唯一标题。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;应该包含&lt;/th&gt;
&lt;th&gt;应该省略&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;用读者的语言描述的结果&lt;/td&gt;
&lt;td&gt;用团队的语言描述的实现方式&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;谁受影响，按套餐、角色或 API 版本区分&lt;/td&gt;
&lt;td&gt;&amp;quot;部分用户&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;需要采取的操作，或&amp;quot;无需操作&amp;quot;&lt;/td&gt;
&lt;td&gt;沉默，读者会用最坏的情况来填补它&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;任何有截止日期的事项的具体日期&lt;/td&gt;
&lt;td&gt;用版本号代替日期&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;指向说明文档的链接&lt;/td&gt;
&lt;td&gt;指向 pull request 的链接&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户报告过的修复，以及被提高的限制&lt;/td&gt;
&lt;td&gt;内部工单 id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;放在最后、每行一句的无趣部分&lt;/td&gt;
&lt;td&gt;与新闻混在一起的无趣部分&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;发布说明和&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志（changelog）条目&lt;/a&gt;之间的划分，正是让这份清单成立的原因：体验日志保留一切，因此说明才可以省略一些内容。每种条目类型的带注释示例，收集在&lt;a href=&quot;https://changeloop.dev/blog/zh/release-notes-examples/&quot;&gt;发布说明示例&lt;/a&gt;里。&lt;/p&gt;
&lt;h2&gt;每一条发布说明都要回答的问题&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;读者现在能做什么以前做不到的事，以及他们需要为此做什么?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果一条内容无法回答这个问题，它就该留在体验日志里，而不该出现在发布说明中。两半都很重要。前半部分是价值所在，后半部分则是团队最容易忘记的部分，也正是它缺失时会引来大量支持工单的部分。&lt;/p&gt;
&lt;p&gt;后半部分真正发挥作用的两个例子：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;现有的 webhook 会一直正常工作到 11 月 1 日。此后，未签名的负载将被拒绝。&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;无需任何操作。现有的导出文件会在你下次打开时自动重新编码。&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第二个例子明确说出了&amp;quot;无需操作&amp;quot;。这句话每次都值得写，因为找不到它的读者会自动假设最坏的情况。&lt;/p&gt;
&lt;h2&gt;发布说明应该怎样排序&lt;/h2&gt;
&lt;p&gt;按照对读者造成的后果来排序，绝不要按系统内部哪个部分发生了变化来排序。按 API、仪表盘、移动端和基础设施分组，反映的是你的组织架构，而不是读者的问题。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;破坏性变更和任何有截止日期的内容。&lt;/strong&gt; 始终放在最前面，即使它很小。如果读者只读了一行就停下，那一定得是这一行。如果截止日期是服务下线，这条内容读起来就应该像一份&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;停用通知&lt;/a&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;他们会想要的新功能。&lt;/strong&gt; 每段一条，把结果放在第一句里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;变得更好的地方。&lt;/strong&gt; 被报告过的修复、被提高的限制、变慢的地方。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;其余的一切，作为列表。&lt;/strong&gt; 依赖更新、内部重构、细小的文案改动。每项一行。几乎没人会读这部分，但它仍然应该存在，因为需要它的那个人真的需要它。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;重写示例&lt;/h2&gt;
&lt;p&gt;之前：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;v4.2.0&lt;/strong&gt; 修复了 &lt;code&gt;POST /exports&lt;/code&gt; 端点在高负载下偶尔返回 500 的问题。重构了导出 worker。将 &lt;code&gt;node-pg&lt;/code&gt; 升级到 8.11。改进了 CSV 序列化器中的错误处理。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;之后：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;大型账户的导出不再失败。&lt;/strong&gt;
拥有约 5 万行以上数据的账户在开始导出时可能会收到 500 错误，月末尤其频繁。这个问题已经修复，现在无论大小，导出都会在失败时自动重试，而不是直接失败。无需任何操作，过去一周内失败过的导出可以直接重新运行。&lt;/p&gt;
&lt;p&gt;4.2.0 中的其他改动：&lt;code&gt;node-pg&lt;/code&gt; 升级到 8.11，CSV 序列化器的错误信息更加清晰。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;同一个版本。第二个版本说出了受影响的账户范围、最严重的时间段、发生了什么变化，以及该怎么做。依赖升级并没有消失，只是不再是标题。&lt;a href=&quot;https://changeloop.dev/blog/zh/release-notes-best-practices/&quot;&gt;发布说明最佳实践&lt;/a&gt;一文列出了这次重写所遵循的其余规则，并说明了跳过每一条各自会付出什么代价。&lt;/p&gt;
&lt;h2&gt;值得删除的内容&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;我们很高兴地宣布。&amp;quot;&lt;/strong&gt; 读者此刻还没有高兴的理由，让下一句话去赢得这种情绪。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内部工单编号。&lt;/strong&gt; &lt;code&gt;PROJ-4471&lt;/code&gt; 在你的追踪系统之外没有任何意义。如果确实需要一个参考，就链接到文档页面。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只有团队自己使用的组件名称。&lt;/strong&gt; 如果你把&amp;quot;ingest pipeline&amp;quot;改名了，就说&amp;quot;导入&amp;quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;仅作为唯一标题的版本号。&lt;/strong&gt; &lt;code&gt;v4.2.0&lt;/code&gt; 只是一个归档标签，不是摘要。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没人访问过的设置页面截图。&lt;/strong&gt; 展示实际改变了的东西正在被使用的样子。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;发布说明应该多久发布一次&lt;/h2&gt;
&lt;p&gt;在有事发生时发布，而不是按固定的时间表发布。每个版本都发的说明，会训练所有人去忽略它。只在有事发生时才到达的说明，才会被打开。完全可以，也通常是正确的做法：发布一个不带任何说明的版本，把它的内容并入下一批真正有标题值得一读的说明中。&lt;/p&gt;
&lt;p&gt;体验日志仍然会记录下这一切。这就是分工：体验日志是完整的，说明是经过挑选的。如果你在开发过程中始终把体验日志维护成结构化的样子，写说明就是挑选和重写的工作，而不是考古。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;就是我们用来完成挑选这一步的形式，而&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;收集了那些体验日志维护得足够好、足以从中提炼出说明的团队的条目。&lt;/p&gt;
&lt;p&gt;以上这一切，默认前提都是一个你能完全掌控的页面，没有字数上限，链接也能正常点开。&lt;a href=&quot;https://changeloop.dev/blog/zh/mobile-app-release-notes/&quot;&gt;移动应用的发布说明&lt;/a&gt; 讲解了当这个界面变成一个 App Store 或者 Play Store 页面时，情况到底会有什么不同。
&lt;a href=&quot;https://changeloop.dev/blog/zh/emergency-release-notes/&quot;&gt;紧急发布说明&lt;/a&gt; 讲解了另一种例外情况：当完全没有时间遵循
正常写作流程时，情况到底会有什么不同。&lt;/p&gt;
&lt;h2&gt;发布前的一个测试&lt;/h2&gt;
&lt;p&gt;把自己当成一个休假两周、只有 40 秒时间的人，去读这份说明。如果在这段时间里，他判断不出自己需要做些什么，那么无论这份说明多么准确，它都还没有完成。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;发布说明应该有多长?&lt;/strong&gt;
只需要和有实际后果的变化所需要的一样长，不要更长。一个版本里有一个破坏性变更和两个改进，三段就足够了。把一个平淡的版本硬凑得看起来很充实，正是读者学会跳过说明的原因。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明应该由谁来写?&lt;/strong&gt;
由理解这个变化的人来写，再由不了解它的人来编辑。工程师知道发生了什么变化；编辑知道陌生人会误解哪些地方。在合并代码时就趁工程师还记得细节把说明写下来，这个习惯让这一切变得便宜。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明应该包含错误修复吗?&lt;/strong&gt;
应该，只要是有人报告过或遇到过的问题。描述读者看到的症状，而不是原因。&amp;quot;超过 5 万行的导出会失败&amp;quot;是读者能认出的错误修复；&amp;quot;修复了导出 worker 中的一个竞态条件&amp;quot;是一条提交信息。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明和体验日志的区别是什么?&lt;/strong&gt;
体验日志是完整的、持续更新的记录；发布说明是关于某一个版本、经过挑选的信息，写给那些还没决定是否关心的人看。更完整的答案在&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明&lt;/a&gt;一文中。&lt;/p&gt;
</content:encoded></item><item><title>Keep a Changelog，真正落地实践之后的心得</title><link>https://changeloop.dev/blog/zh/keep-a-changelog-implemented/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/keep-a-changelog-implemented/</guid><description>Keep a Changelog 这份规范只有短短一页，可真正落实时，大多数团队会渐渐偏离。本文说明它写了什么、刻意没回答什么，以及执行时常在哪里出错。</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Keep a Changelog 是一份关于 &lt;code&gt;CHANGELOG.md&lt;/code&gt; 的一页纸惯例：最新版本排在最前，每个版本一个小节，带有版本号和 ISO 日期，条目按六种类型分组（Added、Changed、Deprecated、Removed、Fixed、Security），顶部还有一个 Unreleased 小节用来放发布之间的条目。大多数引用它的团队只落实了大约三分之二，而他们放弃的那三分之一，恰恰是保护用户的那三分之一。&lt;/p&gt;
&lt;p&gt;Olivier Lacan 在 2014 年发布了 &lt;a href=&quot;https://keepachangelog.com/&quot;&gt;Keep a Changelog&lt;/a&gt;，其中一句话比大多数软件领域的文字都更经得起时间考验：&lt;em&gt;别让你的朋友把 git log 直接倒进体验日志里&lt;/em&gt;。十年过去，它已经是这个软件角落里最接近标准的东西了。直接读原文比读摘要更值得，这篇文章谈的正是那些容易被丢掉的部分。&lt;/p&gt;
&lt;h2&gt;Keep a Changelog 要求了什么&lt;/h2&gt;
&lt;p&gt;在仓库根目录放一份 &lt;code&gt;CHANGELOG.md&lt;/code&gt;，最新的排在最前，每个版本一个小节。每个版本都带有版本号和 ISO 日期，并把条目分到六种类型下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;th&gt;丢掉它的代价&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Added&lt;/td&gt;
&lt;td&gt;新功能&lt;/td&gt;
&lt;td&gt;没有代价；没人会丢掉这个&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed&lt;/td&gt;
&lt;td&gt;现有行为的变化&lt;/td&gt;
&lt;td&gt;读者只能从报错中发现行为已经变了&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deprecated&lt;/td&gt;
&lt;td&gt;即将被移除的功能&lt;/td&gt;
&lt;td&gt;一次移除会变成事故，而不是一次有计划的事件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;td&gt;本次发布中被移除的功能&lt;/td&gt;
&lt;td&gt;没人能分清这是一次移除还是一个 bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed&lt;/td&gt;
&lt;td&gt;错误修复&lt;/td&gt;
&lt;td&gt;没有代价；这个也没人会丢掉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;安全漏洞&lt;/td&gt;
&lt;td&gt;唯一正在搜索它的那位读者会找不到它&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;再加上顶部的一个 &lt;code&gt;Unreleased&lt;/code&gt; 小节，这样条目一旦合并就有地方可以放，任何人也都能看到即将到来的内容。&lt;/p&gt;
&lt;p&gt;这几乎就是全部内容了。剩下的都是理由：条目是写给人看的，每个变化一条条目，这份文件是一份文档，而不是一份日志。&lt;/p&gt;
&lt;h2&gt;Keep a Changelog 的哪些部分容易被丢掉&lt;/h2&gt;
&lt;p&gt;依次是 Unreleased 小节、接着是六种类型中的四种，Security 也在其中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Unreleased&lt;/code&gt; 最先消失。&lt;/strong&gt; 它是没有截止日期压着的那个小节，所以维护它的动作也最先停下来，一旦它消失，条目就会在发布时从提交历史里被临时写出来。这正是规范一开篇就在警告的 git log 大杂烩，只不过是逐步走到这一步的。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-automation/&quot;&gt;体验日志自动化&lt;/a&gt;一文谈的大体上就是，如何在没人特意记得的情况下让这个小节保持活跃。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;六种类型会坍缩成两种。&lt;/strong&gt; 大多数真实的体验日志最后都只剩下 Added 和 Fixed，因为 Changed 和 Deprecated 需要对&amp;quot;有人依赖了什么&amp;quot;做出判断，而这种判断正是有价值的部分。尤其是 Deprecated，它是唯一一种关于未来的承诺，丢掉它正是一次移除演变成事故的原因；维持这份承诺的具体做法在&lt;a href=&quot;https://changeloop.dev/blog/zh/api-deprecation/&quot;&gt;如何停用一个 API&lt;/a&gt;一文中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security 不再被单独列出。&lt;/strong&gt; 一条归在 Fixed 下的安全修复，对唯一正在搜索它的那位读者来说是不可见的。即使修复很小，也要把它单独列出来，尤其是在你并不想引人注意的时候。&lt;/p&gt;
&lt;h2&gt;这份规范没有回答什么&lt;/h2&gt;
&lt;p&gt;它是一种文件格式。对于你采用它之后立刻会遇到的那些问题，它什么都没说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;别人怎么知道?&lt;/strong&gt; 仓库里的一份文件能触达贡献者，触达不了从未打开过 GitHub 的客户。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有版本号的产品怎么办?&lt;/strong&gt; 一个持续部署的服务没有 v4.2.0 可以用来分组。大多数团队会改用日期，这样也行得通，规范既不认可也不禁止这种做法。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;谁来写条目?&lt;/strong&gt; 规范假设是人来写，但没说是什么时候写。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多个不同的读者群体怎么办?&lt;/strong&gt; 一份文件服务的是开发者。它没法把同样的内容提供给一个非技术背景的管理员，为他们手工重新排版正是重复工作开始的地方。&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明&lt;/a&gt;正是这份规范留给你自己去做的那次拆分。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个想法更严格的一个分支，&lt;a href=&quot;https://common-changelog.org/&quot;&gt;Common Changelog&lt;/a&gt;，收紧了其中一些地方：它禁止某些条目的措辞方式，要求链接到具体的变化，并且对读者是谁有明确的立场。如果 Keep a Changelog 那些比较松散的地方正是你团队一直在争论的内容，那么这份规范值得一读。&lt;/p&gt;
&lt;h2&gt;能不能在不倾倒 git log 的情况下自动化 Keep a Changelog&lt;/h2&gt;
&lt;p&gt;可以：从结构化的提交推导出草稿，把它放进 Unreleased 里并预填好类型，并要求人在发布前编辑措辞。规范的警告针对的是最终结果，而不是工具本身。从提交推导草稿没问题，未经编辑就直接发布这份草稿，才是它所反对的事情。&lt;/p&gt;
&lt;p&gt;机器负责收集和排版，这是它擅长的。人负责挑选和措辞，这不是机器擅长的。&lt;a href=&quot;https://changeloop.dev/blog/zh/conventional-commits-changelog/&quot;&gt;Conventional commits&lt;/a&gt; 讲的正是这套推导所依赖的两层拆分，以及哪种提交类型该映射到上面六种分类里的哪一种。我们的&lt;a href=&quot;https://changeloop.dev/changelog-tools&quot;&gt;体验日志工具&lt;/a&gt;汇总涵盖了收集这一半所需要的东西。&lt;/p&gt;
&lt;h2&gt;Keep a Changelog 会在哪里变得不够用&lt;/h2&gt;
&lt;p&gt;它止步于分发环节。Keep a Changelog 对&amp;quot;这份文件应该长什么样&amp;quot;给出了一个好答案，但对&amp;quot;我们的用户如何得知发生了什么变化&amp;quot;却没有给出答案，因为仓库里的一份 Markdown 文件，只有在你的用户就是贡献者时才是一种有效的分发策略。&lt;/p&gt;
&lt;p&gt;这正是大多数团队第二个会撞上的缺口：文件本身没问题，只是团队之外没人读它。要解决这个问题，就意味着条目必须变成能够渲染到别处的数据，这和排版一份文件是完全不同的问题，这也是为什么&lt;a href=&quot;https://changeloop.dev/changelog-examples&quot;&gt;体验日志范例&lt;/a&gt;收集的是公开的体验日志页面，而不是仓库文件。如何把这些条目变成人们会真正回来查看的东西，&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-page/&quot;&gt;如何搭建体验日志页面&lt;/a&gt;一文有详细说明。&lt;/p&gt;
&lt;p&gt;不管怎样，还是采用这份规范吧。它只需要花上一个下午，能让第二个问题变得可以处理，而且它至今仍是关于这个主题写得最好的一页纸。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Keep a Changelog 是一个标准吗?&lt;/strong&gt;
它是一种被广泛采用的惯例，而不是某个标准组织制定的规范。各种工具（发布脚本、代码检查工具、解析器）足够频繁地假设它就是这种形状，所以遵循它能换来兼容性。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unreleased 小节里应该放什么?&lt;/strong&gt;
放每一个已经合并、但还没有在一个有编号的发布中出现过的变化对应的条目。当一次发布被切出来时，这个小节会被重命名为版本号和日期，一个全新的空 Unreleased 小节会出现在它上方。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;体验日志应该使用语义化版本号吗?&lt;/strong&gt;
Keep a Changelog 推荐使用，但并不要求。库和 API 能从中受益；持续部署的服务通常改用日期，这种格式也能容纳这种做法。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;安全修复应该在公开之前就出现在体验日志里吗?&lt;/strong&gt;
在修复上线时就添加这条条目，只包含足够让运维人员采取行动的细节，不要更多。把条目推迟到统一披露日期是正常的；完全省略它则不是。&lt;/p&gt;
</content:encoded></item><item><title>值得团队真正长期坚持采用的发布说明最佳实践</title><link>https://changeloop.dev/blog/zh/release-notes-best-practices/</link><guid isPermaLink="true">https://changeloop.dev/blog/zh/release-notes-best-practices/</guid><description>关于发布说明的大多数最佳实践清单，只是写作风格建议，并没触及问题的本质。本文谈的是能切实改变读者行为的做法，以及另外三个流传很广却几乎没有价值的常见误区。</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;真正重要的发布说明最佳实践，都是带有明确后果的：在合并代码时就写好条目，说明谁会受到影响，即使不需要任何操作也要明确说出来，为破坏性变更标注日期，每个变化只保留一条永久条目，按结果分组，并且保留那个无趣的部分。这些做法每一条都会改变读者的实际行为。这个主题上的大多数其他建议，改变的只是说明看起来的样子。&lt;/p&gt;
&lt;p&gt;搜索发布说明最佳实践，得到的多半是写作风格上的建议：写得清楚一点，简洁一点，用平实的语言，加一些截图。这些建议没有一条是错的，但也没有一条真正改变了什么，因为从来没有哪个团队坐下来是打算把话写得含糊不清的。下面这些做法，每一条都附带了跳过它会付出的代价，因为一个没有失败场景的做法，充其量只是个人偏好。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;跳过它的代价&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;在合并时写条目，而不是在发布时&lt;/td&gt;
&lt;td&gt;之后再重构的条目只会写&amp;quot;若干改进&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;明确点名谁受影响&lt;/td&gt;
&lt;td&gt;每个读者都会认定这与自己无关&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;说明必需的操作，包括&amp;quot;无需操作&amp;quot;&lt;/td&gt;
&lt;td&gt;收到四十张一模一样的支持工单，读者会假设最坏的情况&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;给破坏性变更标注日期，而不是版本号&lt;/td&gt;
&lt;td&gt;截止日期过去之后才被发现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;每个变化保留一条永久、可链接的条目&lt;/td&gt;
&lt;td&gt;没人能回答&amp;quot;这是什么时候变的&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;按结果分组，而不是按系统分组&lt;/td&gt;
&lt;td&gt;读者需要了解你的架构才能找到相关部分&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;保留那个无趣的部分&lt;/td&gt;
&lt;td&gt;安全团队、合规审查员和排查版本不一致问题的人都会失去唯一的信息来源&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;发布说明的最佳实践有哪些&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;在合并代码时写条目，而不是在发布时。&lt;/strong&gt;
跳过的代价：从提交历史重构发布内容的人，并不是做出这个改动的人，他只能猜测意图。两周后才补写的条目，通常都只会写&amp;quot;若干改进&amp;quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;明确点名谁受影响。&lt;/strong&gt;
&amp;quot;使用 Business 套餐的团队&amp;quot;、&amp;quot;任何使用 v1 导出 API 的人&amp;quot;、&amp;quot;在 Postgres 14 上自托管的安装&amp;quot;。跳过的代价：每个读者都得自己判断是否与自己有关，而大多数人会认定无关。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;说明必需的操作，即便是&amp;quot;无需操作&amp;quot;也要说出来。&lt;/strong&gt;
跳过的代价：支持团队要把同一个问题回答四十遍，而没有主动询问的读者只会假设需要做点什么，然后一拖再拖。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为破坏性变更标注日期，而不是发布版本号。&lt;/strong&gt;
&amp;quot;在 v5 中移除&amp;quot;对不清楚 v5 何时发布的人毫无意义。&amp;quot;11 月 1 日起停止工作&amp;quot;是一个人人都能放进日历里的日期。跳过的代价：截止日期过去之后才被发现。什么才算破坏性变更，以及发布它的检查清单，都在&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是破坏性变更&lt;/a&gt;一文中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每个变化只保留一条永久、可链接的条目。&lt;/strong&gt;
一封邮件不是档案，一条 Slack 消息也不是参考资料。跳过的代价：六个月后没人能回答&amp;quot;这是什么时候变的&amp;quot;，包括你自己。邮件依然有它自己的职责，&lt;a href=&quot;https://changeloop.dev/blog/zh/product-update-email/&quot;&gt;产品更新邮件模板&lt;/a&gt;一文对此有详细说明，它指向那条记录，而不是取代它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;按结果分组，而不是按系统分组。&lt;/strong&gt;
跳过的代价：读者必须把你的系统架构记在脑子里，才能弄清楚哪部分和自己有关。由此而来的排序方式，在&lt;a href=&quot;https://changeloop.dev/blog/zh/how-to-write-release-notes/&quot;&gt;如何写发布说明&lt;/a&gt;一文中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;保留那个无趣的部分。&lt;/strong&gt;
依赖升级和内部变更依然留在最后，每项一行。跳过的代价：安全团队、合规审查员，以及排查版本不一致问题的人，都会失去他们唯一的信息来源。最容易在这方面出错的是修复类条目；&lt;a href=&quot;https://changeloop.dev/blog/zh/bug-fix-release-notes/&quot;&gt;缺陷修复发布说明&lt;/a&gt;介绍了怎样写，才能让读者知道是否需要采取行动。&lt;/p&gt;
&lt;h2&gt;体验日志的最佳实践是什么，它和发布说明有何不同&lt;/h2&gt;
&lt;p&gt;体验日志是一份参考资料，因此它的实践关注的是完整性和结构，而不是说服力。四条真正重要的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;每一行固定一种条目类型。&lt;/strong&gt; Added、Changed、Deprecated、Removed、Fixed、Security。这不是风格规范，而是一个过滤器：它让人能够要求&amp;quot;只看破坏性变更&amp;quot;。&lt;a href=&quot;https://changeloop.dev/blog/zh/keep-a-changelog-implemented/&quot;&gt;Keep a Changelog&lt;/a&gt; 这个惯例通常就是这套分类的来源。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个未发布部分。&lt;/strong&gt; 用来存放合并之后、发布之前的条目。它的缺失正是团队总是拖到最后才补写条目的原因。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ISO 日期格式。&lt;/strong&gt; 用 &lt;code&gt;2026-08-28&lt;/code&gt;，而不是 &lt;code&gt;28/08/26&lt;/code&gt;，后者在不同读者眼里会变成两个不同的日期。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个变化一条条目，而不是每次提交一条。&lt;/strong&gt; 三次修复同一个 bug 的提交，应该只算一条条目。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这两种成果物在&lt;a href=&quot;https://changeloop.dev/blog/zh/changelog-vs-release-notes/&quot;&gt;体验日志与发布说明&lt;/a&gt;一文中有更完整的比较；简单说就是，体验日志的实践守护的是完整性，发布说明的实践守护的是读者的注意力。
&lt;a href=&quot;https://changeloop.dev/blog/zh/private-release-notes-enterprise/&quot;&gt;专门面向企业客户来撰写的这份私有发布说明&lt;/a&gt;讲的
是这件事的另一个版本，它只有当你的客户不再全都用着同一个构建版本时才会浮现出来：同样是守
护完整性和注意力这两个目标，只是要按账户分别校准，而不是一次性广播给所有人。&lt;/p&gt;
&lt;h2&gt;三个盲目跟风的误区&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;用表情符号作为条目类型。&lt;/strong&gt; 一个火箭图标和一个扳手图标构不成分类体系。它们看起来整齐，却无法被过滤、排序，也无法被屏幕阅读器有效读出。用文字来表达，如果确实想加表情符号，就把它放在文字后面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;用语义化版本号作为托管产品的标题。&lt;/strong&gt; 语义化版本控制是关于 API 兼容性的一种承诺。对于一个没人自己选版本的 SaaS 产品来说，标题里的版本号只是把内部归档信息打扮成新闻。把语义化版本留在体验日志里，不要放进发布公告。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不论内容如何都按固定时间表发布。&lt;/strong&gt; 内容空洞的月度说明，只会教会人们把你的说明当成噪音。有话可说的时候再发布，其余的交给体验日志去记录。&lt;/p&gt;
&lt;h2&gt;唯一真正困难的那件事&lt;/h2&gt;
&lt;p&gt;在不把内容写两遍的前提下，让体验日志和发布公告保持同步。&lt;/p&gt;
&lt;p&gt;大多数团队一开始都从一个页面起步，等到读者群体开始分化时才把它拆开，然后悄悄放任其中一个腐坏，通常是体验日志，因为它是那个没有截止日期压着的。走出困境的办法在于结构，而不是自律：把条目当作带有类型、日期和目标读者的数据来维护，把两个展示面都当成这份数据的渲染结果。我们的&lt;a href=&quot;https://changeloop.dev/changelog-tools&quot;&gt;体验日志工具&lt;/a&gt;汇总涵盖了这方面的现有选择，包括我们的竞争对手，而&lt;a href=&quot;https://changeloop.dev/beamer-alternative&quot;&gt;Beamer 替代方案&lt;/a&gt;页面则是与大多数团队最初使用的那款小组件的诚实对比。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;就是条目已经存在之后，挑选这一步真正发生的地方。&lt;/p&gt;
&lt;h2&gt;如果只能采纳一件事&lt;/h2&gt;
&lt;p&gt;在合并代码时，以固定格式，带上类型，把条目写下来。这个页面上的其他每一条实践，一旦这一点落实了都会变得容易，而离开它，没有一条能够真正生效。&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;发布说明应该配截图吗?&lt;/strong&gt;
只应该配上实际改变的东西正在被使用的截图。没人访问过的设置页面截图只会增加滚动长度，不会增加信息量。一段说清结果和受影响读者的文字，胜过一张两者都没说清的图片。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;破坏性变更的发布说明应该怎么写?&lt;/strong&gt;
先写日期，其次是受影响的调用方，再是必需的操作，最后是迁移方法。绝不要以版本号开头。完整的格式连同示例条目，都在&lt;a href=&quot;https://changeloop.dev/blog/zh/breaking-changes/&quot;&gt;什么是破坏性变更&lt;/a&gt;一文中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布说明应该由工程团队来写，还是由市场团队来写?&lt;/strong&gt;
由做出这个改动的工程师在合并代码时起草，再由把它当成陌生人来阅读的人来编辑。单靠任何一方都写不出客户能够据以行动的说明。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;理想的发布说明格式是什么样的?&lt;/strong&gt;
先写有截止日期的事项，再写新功能，然后是改进，最后是每项一行的其余内容列表。&lt;a href=&quot;https://changeloop.dev/release-notes-template&quot;&gt;发布说明模板&lt;/a&gt;就是这种格式做成的一份填空页面。&lt;/p&gt;
</content:encoded></item></channel></rss>