docs(review): apply batch-04 consultant fixes and approve 5 tutorial/web3 posts
Deploy / build (push) Successful in 22s

- performUpkeep: external callable, re-check is mandatory security (not stale-data hygiene)
- BasicNft.tokenURI: ownerOf -> _ownerOf, dead custom error explained
- storage (SSTORE) vs EIP-170 runtime bytecode separated, derived ~1.3M gas for the two SVGs
- subs-15 retimed from a fresh faster-whisper word alignment (10.84s first line, was 28.22s) + SRT regenerated
- RDAP needs curl -L (bootstrap 302), ASN/Spamhaus blocklist check added
- learning-project framing -> Sepolia reference implementation / testnet validation build
- dynamicCta on the two posts that ended with no conversion block
- cross-links between the two NFT posts; tracker 91/91 approved
This commit is contained in:
hoelee committed 2026-10-27 06:13:00 +08:00
1 parent 01c55805fb
commit cd6511a0b3
14 files changed
+322 -166

No files matched your search

@@ -18,8 +18,9 @@ draft: false
预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定
「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约
本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。
这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来,
因为这篇文章的可信度就靠它。
这是一个部署在 Sepolia 测试网上的**参考实现与验证沙盒**,不是经过审计的
主网资金级代码——我把这条边界先划清楚,而一份真钱版本必须补齐的四项加固,
我列在文末。
## 为什么这是预言机少数真正有用的场景之一
@@ -121,7 +122,7 @@ contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
`Raffle__RaffleNotOpen`。然后把发送者压栈,发出事件:
```solidity
if (msgValue < i_entranceFee) {
if (msg.value < i_entranceFee) {
revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
@@ -141,7 +142,11 @@ bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```
**开奖。** `performUpkeep` 只能被网络调用,但它仍然重新检查
**开奖。** 正常流程下 `performUpkeep` 由 Chainlink Automation 节点触发,但接口
要求它必须是一个无权限修饰符的 `external` 函数 —— 链上任何地址或合约都能直接调用
它,而且一笔有效的 keeper 交易也可能落在链下模拟之后状态已变的区块上。所以在
`performUpkeep` 内部重新执行 `checkUpkeep` 是必须的安全边界,不是形式主义:正是它
挡住了任意外部调用者在条件未满足时强行触发开奖。它仍然重新检查
`checkUpkeep`——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。
检查失败时它回滚一个自定错误,错误里自带证据:
@@ -276,10 +281,11 @@ yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/
## 重做时我会改什么
这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。
这是一个 Sepolia 参考实现,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。它存在的目的是端到端验证一套状态机与 keeper 合约,
不是收存款。
运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会
运营上,一个真彩票有个测试网参考实现没有的持续成本:VRF 订阅每次请求都会
消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值——
我会写一个小监控脚本,给订阅补仓、余额太低时报警。
+35 -18
View File
@@ -15,14 +15,14 @@ draft: false
集合里,这个字符串指向*别处*:一个 `ipfs://` CID,一个 HTTPS URL。
代币是永久的,它指向的东西,却是一个没有任何合约能控制的外部依赖。
这是一个学习项目,不是生产代码 —— README 里写得很明白 —— 而且这里的
一切都跑在 Sepolia 测试网上。我把它写下来,是因为全链上这个模式确实
有用,下面的数字都是真实的,代码也小到可以一口气读完。
这是一个测试网参考实现,不是生产代码 —— README 里写得很明白 —— 而且这里
一切都跑在 Sepolia 上。我把它写下来,是因为全链上这个模式确实有用,
下面的数字都是真实的,代码也小到可以一口气读完。
## 如何铸造一个艺术作品永不消失的 NFT?
如果你是个刚学 Solidity 的新手,拿这个问题去搜,大部分答案都会把你
引向 IPFS,再配一句关于 pinning 的 shrug。对小尺寸的艺术品,有更好的
如果你拿这个问题去搜,大部分答案都会把你引向 IPFS,再配一句关于
pinning 的 shrug。
答案:把艺术*放进合约里*,编码好,然后给钱包一个完整的 `data:` URI,
让元数据 JSON 和图片都在同一个字符串里。这样一来,艺术作品就是链上
的字节,和代币本身没有区别。
@@ -56,7 +56,7 @@ contract BasicNft is ERC721 {
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
if (_ownerOf(tokenId) == address(0)) {
revert BasicNft__TokenUriNotFound();
}
return s_tokenIdToUri[tokenId];
@@ -65,9 +65,14 @@ contract BasicNft is ERC721 {
```
它刻意做得非常小。`mintNft(tokenUri)` 把你给的字符串按代币存起来,
`tokenURI` 原样返回;如果代币从未被铸造,就触发一个自定义错误
(比带字符串的 `require` 更省 gas,也自带文档)。我用自己的名字命名
集合,因为重点是光明正大地学习:`ERC721("Hoelee", "HOE")`。
`tokenURI` 原样返回。这里真正有意思的是那行存在性检查:它调用的是
OpenZeppelin 的**内部**函数 `_ownerOf(tokenId) == address(0)`。如果写成
`ownerOf(tokenId) == address(0)`(我第一版就是这么写的),编译照样通过,
但那是一段死代码:公有函数 `ownerOf` 在 OpenZeppelin 内部就直接 revert 了
(v5 抛 `ERC721NonexistentToken`,v4 是 `require`),根本不可能返回
`address(0)`,所以你自定义的错误永远不可能被触达。用 `_ownerOf` 才会真正
生效;而自定义错误比带字符串的 `require` 更省 gas,也自带文档。集合用我
自己的名字命名:`ERC721("Hoelee", "HOE")`。
这个合约对 URI 指向什么没有任何意见 —— OpenZeppelin 的 `ERC721` 基类
也没有 —— 所以艺术住在 URI 所在的地方。
@@ -288,15 +293,21 @@ mintNftOnContract(mostRecentDeployed);
## 如果重来,我会怎么做
**链上艺术要付部署 gas,而且是永久的。** 两张 SVG 的每一个字节都在
部署时一次性付清,然后以构造函数字符串的形式永远住在合约存储里。
这是一次性成本,但它是真实的,并且随艺术尺寸增长。
**链上艺术付的是存储 gas,部署时一次性付清。** 在 `MoodNft` 里,两个
`data:image/svg+xml;base64,` 字符串作为构造函数参数传入,通过 `SSTORE` 写进
合约**存储**(Storage)—— 每个从零写满的 32 字节槽位大约 20,000 gas。仓库里
这两张编码后的 SVG 分别是 542 和 1,469 字节,也就是 17 + 46 个数据槽(每个
字符串再加一个长度槽),所以光艺术本身在部署时就要花掉 **130 万 gas** 量级,
合约逻辑一行都还没跑。这是一次性成本,但它随载荷体积线性增长。
**能放进去的艺术有上限。** EIP-170 把合约代码限制在 24,576 字节以内,
艺术字节要和逻辑共用这个预算。笑脸和哭脸这种矢量 SVG 是理想选择 ——
小、清晰、可缩放。照片或 3D 模型永远塞不进去,硬塞也只是浪费 gas。
我的 `BasicNft` 部署返回 `4102 bytes of code`;
`MoodNft` 的字节码还要再叠上两张 SVG。
**存储与字节码是两个不同的天花板。** 因为艺术是以构造函数参数的形式进去的,
限制它的是区块 gas 上限和 EIP-3860 的 49,152 字节 `initcode` 上限 —— 不是部署
后的代码体积。大家常引用的 24,576 字节是 **EIP-170**,管的是运行时字节码:
编译后的逻辑,加上你内联成 `constant` 或 `immutable` 的任何东西。如果我为了
省掉每次读取的 `SLOAD`,把同样的 SVG 硬编码成 `constant` 字符串,这些字节就
会搬进运行时字节码,开始和逻辑抢 EIP-170 的预算。笑脸这种紧凑的矢量艺术两个
方向都很宽裕(`BasicNft` 部署返回 `4102 bytes of code`);照片或 3D 模型两个
方向都塞不进去,硬塞只是烧 gas。
**不是每个项目都该这么做。** 如果艺术很大、集合需要可变元数据、或者
部署预算紧张,IPFS 或文件存储加一个*可设置的* base URI 才是务实的默认
@@ -307,7 +318,8 @@ mintNftOnContract(mostRecentDeployed);
**锁死 SVG 的行尾。** 仓库通过 `.gitattributes` 把
`img/*.svg` 固定为 LF,保证编码后的字节是确定的;在 Windows 上,
`core.autocrlf` 会悄悄注入 CRLF,改变编码后的艺术。这种「艺术就是
字节」的模式,对这种隐形 bug 毫不留情。
字节」的模式,对这种隐形 bug 毫不留情。这个跨平台字节陷阱的完整排障过程
见[《为什么在 Windows 上克隆后,我的链上 NFT 图像变了》](/posts/zh/why-my-on-chain-nft-art-changed-on-windows/)。
**从第一天起就用 DevOpsTools。** 对单机学习来说,注释里的硬编码地址
没关系;一旦有第二次部署,它们就是陷阱。
@@ -330,6 +342,11 @@ mintNftOnContract(mostRecentDeployed);
`0.000186204671471742 ETH`。也就是说,在测试网上把 NFT 放上去几乎不
花钱;IPFS 依赖的代价是隐形的,直到某个 pin 死掉的那一天。
`MoodNft` 在仓库里没有部署 trace —— 它是在同一个测试网上用同一个
`forge script` 部署的,但那份日志我没有留下 —— 所以我能诚实给出的只有
上一节那笔存储算术(两张编码后的 SVG 约 130 万 gas),而不是一个实测
总量。这个缺口我宁愿写出来,也不愿意印一个指不到具体文件的数字。
而 `MoodNft` 真正重要的结果根本不需要日志:在 MetaMask 里把部署好的
合约和 0 号代币作为 collectible 添加,艺术作品就直接从链上 base64 SVG
渲染出来 —— 没有 IPFS 网关、没有网络查找、没有任何需要维持在线的东西。
@@ -9,6 +9,7 @@ stream: creative-lab
tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"]
ogImage: "/og/zh-generating-subtitles-for-songs-without-timestamps.jpg"
banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg"
dynamicCta: true
draft: false
---
我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你**每一句是什么时候唱的**。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。
@@ -84,6 +85,8 @@ for i, ch in enumerate(hanzi_in_word):
**故障 B —— 四行挤在 0.6 秒里。** 有一首歌的尾奏被识别器整段漏掉,剩下的行被压到相隔 0.18 秒。12 个字不可能在 0.18 秒里唱完。
**故障 C —— 六行被死死压在硬下限上。** 有一首的整个第一段主歌完全没有可用锚点,于是第 3 条规则把连续六行扫到了最小间隔:每行正好 1.20 秒,大约相当于每秒十个词。输出看起来依然「合理」—— 单调递增、在音频时长范围内 —— 直到我去听那一首,整段主歌六秒就唱完了。只对这一个文件重跑一次对齐,第一行就落到了 10.8 秒而不是 28.2 秒:锚点不是缺失,而是被匹配到了歌里错误的位置。
所以对齐之后,跑三条规则:
1. **估计这首歌自己的速率** —— 用相邻两行都已锚定的样本(单位数 ÷ 秒数,取中位数)。每行的期望时长 = `单位数 ÷ 速率`,下限约 1.8 秒。同一批里英文是 0.96 到 2.33 词/秒,中文是 2.3 到 4.1 字/秒,**不能用全局常数**。
@@ -141,7 +144,7 @@ a.dispatchEvent(new Event('timeupdate'));
## 结果
18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。
18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。上面覆盖率表里的中文那一行,来自另一批单独跑的 11 首中文验证集,不是这一页的播放器。
| | |
|---|---:|
@@ -149,8 +152,12 @@ a.dispatchEvent(new Event('timeupdate'));
| 字幕行数 | 342 |
| 平均锚定覆盖率 | 0.96 |
| 全行锚定的歌 | 9 / 18(每行都有时间) |
| 处理耗时 | 每首 63–90 秒,纯 CPU |
| 占用显卡 | 无 |
| 处理耗时 | 每首 63–90 秒,纯 CPU 跑 `faster-whisper` |
跑在 CPU 上是刻意的,不是我绕不过去的限制:这 18 首全部在 Core i9-13900K 上
(跑这套流程的那台 Windows 虚拟机)完成对齐,因为 GPU 要留给生成这些歌曲的
生产管线,而 `faster-whisper` int8 每首 63–90 秒已经足够便宜 —— 把对齐任务排在
一个扩散生成任务后面等,省下来的那点 CPU 时间根本不值。
下面每个播放器都会滚动歌词、把当前句保持在中间,旁边还有 **.srt 下载** —— 想烧字进视频或在 VLC 里打开都行。
@@ -189,7 +196,7 @@ a.dispatchEvent(new Event('timeupdate'));
[00:11.56]Underneath the floorboards where the shadows play
[00:14.60]I tied a little ribbon at the break of day
[00:18.32]A button for a table, a thimble for a cup
[00:22.02]I climbs the ivy ladder when the sun comes up
[00:22.02]I climb the ivy ladder when the sun comes up
[00:27.76]Sugar on the windowsill, a feather in my hand
[00:31.42]The garden is a jungle and a wonderland
[00:35.22]Careful not to wake the cat sleeping by the door
@@ -509,7 +516,7 @@ a.dispatchEvent(new Event('timeupdate'));
[00:38.16]Counting every falling tear that drops into the tide
[00:44.50]Waiting for the heavy gates of heaven to divide
[00:51.16]Do not fear the rising storm or shadows in the deep
[00:57.24]There is still a ancient promise that the ocean keeps
[00:57.24]There is still an ancient promise that the ocean keeps
[01:06.34]Spread your wings and rise above the sea of clouds and rain
[01:13.18]Never let the mortal world bind you with a chain
[01:19.74]Fly across the mountain peaks where immortal rivers flow
@@ -625,23 +632,23 @@ a.dispatchEvent(new Event('timeupdate'));
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-15" src="https://content.hoelee.com/file/hoelee/music/Green-Train-to-the-Sea-Mr-Hoelee.mp3"></audio>
<pre id="subs-15" hidden>[ti:Green Train to the Sea]
[ar:Mr Hoelee]
[00:28.22]Clickety-clack upon the track, the green train starts to roll
[00:29.42]Leaving all the city gray behind the tunnel hole
[00:30.62]Forehead pressed against the glass, I watch the fields go by
[00:31.82]Golden wheat and windmills waving underneath the sky
[00:33.02]Take me where the spring wind blows across the open blue
[00:34.22]Ten miles of wildflowers blooming just for me and you
[00:38.36]Every station has a name I've never heard before
[00:42.68]Soon the scent of salty air will meet us at the shore
[00:57.62]Strangers sharing oranges and stories in the seat
[01:01.92]Sunlight dancing in the aisle around my dangling feet
[01:06.68]Postcards in my backpack waiting for a stamp and pen
[00:10.84]Clickety-clack upon the track, the green train starts to roll
[00:14.28]Leaving all the city gray behind the tunnel hole
[00:19.42]Forehead pressed against the glass, I watch the fields go by
[00:24.20]Golden wheat and windmills waving underneath the sky
[00:28.38]Take me where the spring wind blows across the open blue
[00:33.78]Ten miles of wildflowers blooming just for me and you
[00:38.82]Every station has a name I've never heard before
[00:43.46]Soon the scent of salty air will meet us at the shore
[00:57.42]Strangers sharing oranges and stories in the seat
[01:02.46]Sunlight dancing in the aisle around my dangling feet
[01:07.48]Postcards in my backpack waiting for a stamp and pen
[01:11.50]To tell the people back at home I'm coming back again
[01:16.24]Take me where the spring wind blows across the open blue
[01:21.14]Ten miles of wildflowers blooming just for me and you
[01:26.34]Every station has a name I've never heard before
[01:30.66]Soon the scent of salty air will meet us at the shore
[01:46.82]Look, there is the sea...</pre>
[01:16.22]Take me where the spring wind blows across the open blue
[01:21.52]Ten miles of wildflowers blooming just for me and you
[01:26.80]Every station has a name I've never heard before
[01:31.40]Soon the scent of salty air will meet us at the shore
[01:46.20]Look, there is the sea...</pre>
</div>
</figure>
@@ -9,6 +9,7 @@ blueprintLayer: L4-managed-ops-handover
tags: [hosting, vps, due-diligence, whois, devops]
ogImage: /og/zh-how-to-verify-a-hosting-provider-before-you-buy.jpg
banner: /banners/how-to-verify-a-hosting-provider-before-you-buy.jpg
dynamicCta: true
---
每家主机服务商的首页都是一串承诺:_「自 2005 年起运营」_、_「保证在线率」_、
@@ -16,7 +17,9 @@ _「注重隐私」_、_「不限流量」_。其中有些是真的,但出人
而且在你刷信用卡、把 DNS 指向他们服务器之前,没人会去核实。
最近我要为一个有具体要求的项目(隐私司法辖区、付款方式、流量上限)筛选一批
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程。它确实揪出了问题——
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程 —— 一轮下来,名单上
大多数都被筛掉了。我没有留下打分表,所以下面只说它揪出了什么问题,不报具体
淘汰率。它确实揪出了问题——
有的服务商声称「运营多年」,但域名比我的手机还新;有的服务商自己条款里就静悄悄地
禁止了我想跑的东西;还有的上了第三方黑名单。
@@ -31,9 +34,13 @@ _「注重隐私」_、_「不限流量」_。其中有些是真的,但出人
最快的办法是查 WHOIS/RDAP。RDAP 是 WHOIS 的现代替代品,返回 JSON,更好解析:
```bash
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
curl -sL "https://rdap.org/domain/example.tld" | python3 -m json.tool
```
`rdap.org` 只是个引导服务(bootstrap server)—— 它会用 `302` 把请求转到真正持有
记录的注册局(`.com` 是 Verisign,依此类推),所以 `-L` 不能省:不加就是空响应,
`json.tool` 直接报 `Expecting value: line 1 column 1`。
在 `events` 数组里找 `registration` 事件——那是**最初**的注册日期,不是最近一次续费。
(续费日期和注册商变更会排在时间线的后面,你要找的是*第一次*注册那条。)
@@ -68,6 +75,12 @@ AUP 告诉你他们*会以什么理由封停你*。这是两份不同的清单
卷款跑路和大规模封停事件有长记忆,而且通常直白得很,会直接说哪家是「知名骗子」。
- **WHTop / HostAdvice**——评分本身很嘈杂,但「1.8/10 且反复出现同一种投诉」和
「1.8 分但只是零散的零星差评」是两码事。
- **ASN 归属与 IP 段黑名单(`bgp.he.net`、Spamhaus)**——从服务商的 Looking Glass
页面拿一个测试 IP,查它宣告的自治系统号(ASN)。一家自称自建机房、ASN 却指向
某家廉价上游的服务商,本质是在转售别人的容量,而不是在运营基础设施;上游的
滥用处理方式,也就成了你的滥用处理方式。如果这个 IP 所在的 `/24` 网段常年挂在
Spamhaus 名单上,那你的发信与 webhook 信誉从第一天起就已经报废 —— 你自己的配置
再干净也没用。
我找到的最有用的单一信号是:**封停报告。** 一家服务商遇到滥用投诉时的第一反应是
「先封停」、第二反应是「收『恢复费』」,这会在某条评论里白纸黑字写出来。那是
@@ -18,8 +18,8 @@ draft: false
部署脚本在**执行部署的那台机器**上恰好从磁盘读到的字节 —— 而在
Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。
这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」
NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
这事发生在[全链上「心情」NFT](/posts/zh/fully-on-chain-svg-nfts/)这个练习里:
一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
两者都编码在合约自身中。图像 URI 在部署时由 `img/` 目录里的源
文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条
坏掉的换行符,如何差点让这幅作品变成「因机器而异」。
@@ -51,8 +51,13 @@ printf 'a\nb' | base64 # YQpi
printf 'a\r\nb' | base64 # YQ0KYg==
```
一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于:
没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。
一个回车符,就改变了编码后的载荷。这里要说精确:真正变掉的**不是画面** ——
多出来的 `
` 落在 XML 标签之间,浏览器画出来的还是同一个笑脸;变掉的是合约
存储的那串字节,而在链看来那就是另一件作品,任何针对这串字符串的 `keccak256`
断言(部署安全测试会写的那类校验)都会在一个「看起来一模一样」的载荷上失败。
这类失败最讨厌的地方在于:没有任何东西会告诉你。SVG 在任何一个编辑器里看起来
都一模一样。
`git status` 依然干净,因为 Git 在比较文本时先做了换行归一化。
Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何
机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于
@@ -79,7 +84,10 @@ img/*.svg text eol=lf
说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的
归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的,
规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能
发生的分歧。
发生的分歧。规则提交之后新做的 clone 会自动是 LF;但如果你是在一个
已经把这些文件按 CRLF 检出过的 Windows 工作区里更新,需要手动执行一次
`git add --renormalize .`(或重新检出 `img/*.svg`)才能刷新磁盘上的字节
—— 光有规则,不会倒回去改你现有的工作区。
同一个 commit 还修了第二件静默出错的东西:`foundry.toml` 里
`remappings` 的一个拼写错误。这条映射决定了
@@ -146,8 +154,8 @@ ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTo
全部意义所在;而它差一点就被一个文件、一条规则、一次 `printf`
就能证明的问题悄悄毁掉。
坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。
坦白说清范围:这是一个测试网验证工程 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 上,不是生产代码。
但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。