Add 5 posts (EN + ZH): fully on-chain SVG NFTs, the CRLF art trap, a Chainlink VRF v2.5 lottery, page-by-page PDF verification, JPA @Version
Deploy / build (push) Successful in 20s

Five posts with custom OG + banner and hire CTAs. Three are web3, giving
that category its first posts — /categories/web3/ previously 404'd while the
index advertised it as '0 posts - coming soon'.

Backdated where the work genuinely is older: the foundry-nft and
hardhat-smartcontract-lottery commits date to 2024-08-15..19, so those two
posts fill the empty 2024-10 and 2024-12 archive months with updatedDate
holding the real date. The two 2026-08-19 posts use their real work date.

TERMINALS/BANNERS entries for all five slugs are committed this time (both
generators were clean; diffs verified purely additive).
This commit is contained in:
2026-09-29 02:41:58 +08:00
parent 9889ef58f8
commit 95b21e3456
23 changed files with 2875 additions and 1 deletions
@@ -0,0 +1,308 @@
---
title: "一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约"
description: "基于 Chainlink VRF v2.5 与 Automation 构建彩票合约:为什么开奖无法被操纵、完整的合约讲解,以及那个让开奖挂掉的 mock 订阅资金不足 bug。"
pubDate: 2024-12-10
updatedDate: 2026-09-29
category: web3
tags: [solidity, chainlink, vrf, foundry, hardhat, blockchain]
ogImage: /og/chainlink-vrf-v2-lottery-contract.png
banner: /banners/chainlink-vrf-v2-lottery-contract.png
draft: false
---
你能在链上运行一个没人能操纵的彩票吗——玩家不能、矿工不能,连部署它
的人也不能?这就是我写 `Raffle.sol` 时想回答的问题,也是这个项目要
用区块链预言机(oracle)的根本原因。
简短的回答是可以,但带着两个诚实的条件。随机数必须来自合约本身无法
预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定
「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约
本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。
这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来,
因为这篇文章的可信度就靠它。
## 为什么这是预言机少数真正有用的场景之一
合约无法自己产生随机数。当前区块的 `blockhash` 可以预测,
`block.timestamp` 由挖出区块的人决定,任何纯 Solidity 的「随机」函数
都是确定性的——每个玩家都能重算出来。链上彩票因此需要一个预言机,
而这是少数几个预言机不是弱点、反而是全部意义所在的场景。
Chainlink VRF(可验证随机函数)返回一个随机数,并附带一份合约在链上
验证的证明。合约在数字到达之前无法预测它,到达之后也无法重掷——
数字在开奖之前就已承诺,而计算出它的模块看不到谁参加了。这正是彩票
需要的性质,也是任何区块哈希都给不了的性质。
另一半是 Chainlink Automation。节点按定时器观察合约,调用
`checkUpkeep`;当它说「对,现在开奖」,节点就调用 `performUpkeep`。
合约自己的文档注释把目标说得很清楚:
```solidity
// Enter the lottery (paying some amount)
// Pick a random winner (verifiably random)
// Winner to be selected every X minutes -> completely automated
// Chainlink Oracle -> Randomness, Automated Execution (Chainlink Keeper)
```
没有 keeper 节点,没有人类——合约回答「现在该抽出赢家吗?」这个问题,
预言机按答案行动。
## 我试了什么,以及那个让开奖挂掉的 bug
项目叫 `hardhat-smartcontract-lottery`:一个 `Raffle` 合约,
用 Hardhat 和 Foundry 两套工具测试,部署并验证在 Sepolia 上。
搭建过程是常规的
VRF v2.5 流程:在 vrf.chain.link 创建订阅、充值、部署 consumer、把它
加进订阅,然后在 30 秒间隔上注册一个 Automation upkeep(间隔、0.01 ETH
入场费、50 万 gas 的回调上限,都放在 `HelperConfig` 里)。
开奖挂了,而且在合约内部完全看不见。在本地,VRF mock 毫无怨言地接受
了请求——然后 fulfill 回滚了。仓库自己的历史记录了这次失败——修复落
在一个标题为 "Fixed InsufficientBalance VRF Mock" 的提交里——痕迹至今
留在测试文件里。Foundry 测试里 `fulfillRandomWords` 调用旁的注释只写
着 `// InsuficientBalance()`,hardhat 测试里有一条更长、更惨的:
`// Here cannot run, always InsufficientBalance()`。
我当初没明白的是:mock 并不比主网宽松——它执行着同样的不变量。在它
愿意服务请求之前,订阅必须存在、必须有余额、必须把彩票合约注册为
consumer,而且 LINK 必须真的流动。mock 和 coordinator 一样记录订阅
余额——JS 测试会读 `getSubscription(...).balance` 回来确认——而在
真实链上,你的钱包用 `transferAndCall` 把 LINK 发给 coordinator,
订阅才会入账。我的订阅要么是空的,要么 consumer 还没挂上,于是
fulfill 被合理地弹了回来。
修复在测试的 setUp 里:Foundry 的 `setUp()` 在*任何测试运行之前*铸造
100 个 LINK 并为订阅充值(`LINK_BALANCE = 100 ether`):
```solidity
vm.startPrank(msg.sender);
if (block.chainid == LOCAL_CHAIN_ID) {
link.mint(msg.sender, LINK_BALANCE);
VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription(
subscriptionId,
LINK_BALANCE
);
}
link.approve(vrfCoordinatorV2_5, LINK_BALANCE);
vm.stopPrank();
```
部署脚本在真实链上做同样的事——`FundSubscription` 用 `transferAndCall`
送出 `FUND_AMOUNT`(3 个 LINK):
```solidity
LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));
```
hardhat 部署脚本会给刚创建的订阅充值。这次教训花了我一个晚上:
**VRF mock 要求订阅先有资金才愿意服务请求**,而 checkUpkeep 自己的
文档注释也隐晦地这么写——"Implicity, your subscription is
funded with LINK." 随机数不是免费的,连在 mock 里也不是。
## 修复:逐段讲解合约
`Raffle` 继承自两个 Chainlink 合约,都来自 v2.5 线:
```solidity
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
import {AutomationCompatibleInterface} from "@chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol";
contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
```
构造函数接收 coordinator 地址、订阅 ID、gas lane(key hash)、间隔、
入场费和回调 gas 上限,除了必须变化的状态外全部 `immutable`。两个
常量很关键:`REQUEST_CONFIRMATIONS = 3` 和 `NUM_WORDS = 1`——
开奖只需要一个随机词,且要等三个区块确认。
**入场。** `enterRaffle` 刻意保持无聊:要么付足够的钱,否则回滚
`Raffle__NotEnoughETHEntered`;要么处于 `OPEN` 状态,否则回滚
`Raffle__RaffleNotOpen`。然后把发送者压栈,发出事件:
```solidity
if (msgValue < i_entranceFee) {
revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
revert Raffle__RaffleNotOpen();
}
s_players.push(payable(msg.sender));
emit RaffleEnter(msg.sender);
```
**决定是否开奖。** `checkUpkeep` 用一行写完了整个 Automation 合约:
```solidity
bool isOpen = RaffleState.OPEN == s_raffleState;
bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval);
bool hasPlayers = s_players.length > 0;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```
**开奖。** `performUpkeep` 只能被网络调用,但它仍然重新检查
`checkUpkeep`——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。
检查失败时它回滚一个自定错误,错误里自带证据:
```solidity
revert Raffle__UpkeepNotNeeded(
address(this).balance,
s_players.length,
uint256(s_raffleState)
);
```
错误数据本身就会告诉你哪个条件失败了。然后状态翻转,VRF 请求发出:
```solidity
s_raffleState = RaffleState.CALCULATING;
VRFV2PlusClient.RandomWordsRequest memory req = VRFV2PlusClient.RandomWordsRequest({
keyHash: i_gasLane,
subId: i_subscriptionId,
requestConfirmations: REQUEST_CONFIRMATIONS,
callbackGasLimit: i_callbackGasLimit,
numWords: NUM_WORDS,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
});
uint256 requestId = s_vrfCoordinator.requestRandomWords(req);
emit RequestedRaffleWinner(requestId);
```
`nativePayment: false` 表示这次请求用订阅里的 LINK 付费——这正是资金
bug 之所以要紧的原因。
**锁。** `RaffleState` 是一个枚举,`OPEN` 和 `CALCULATING`。从
`performUpkeep` 翻转它那一刻起,到 `fulfillRandomWords` 把它重置为止,
彩票处于 `CALCULATING`,`enterRaffle` 一律回滚。没人能在请求与回调
之间溜进来,所以被随机数索引的数组,恰好就是随机数抽取所面对的玩家
集合。防操纵的全部故事,就藏在这一个枚举里。
**抽赢家。** VRF coordinator 会回调 `fulfillRandomWords`,
合约覆写它:
```solidity
uint256 indexOfWinner = randomWords[0] % s_players.length;
address payable recentWinner = s_players[indexOfWinner];
s_recentWinner = recentWinner;
s_players = new address payable[](0);
s_lastTimeStamp = block.timestamp;
s_raffleState = RaffleState.OPEN;
emit WinnerPicked(recentWinner);
(bool success, ) = recentWinner.call{value: address(this).balance}("");
if (!success) {
revert Raffle__TransferFailed();
}
```
这个顺序是故意的,它就是 Checks-Effects-Interactions 模式(合约自己的
注释里点了名)。赢家、玩家数组、时间戳、彩票状态全部在 ETH 转账*之前*
重置。如果赢家恰好是个合约,它的 fallback 想重新入场:没什么可重入的
对象了——状态已经全新,转账失败则由自定错误处理,而不是一句无声的
`require` 字符串。
## 它是怎么被测试的——一套故意保留的混合测试套件
仓库为同一个合约保留了两套单元测试,因为它们抓的是不同的东西。
Hardhat/JS 套件(`Raffle.test.js`)用 hardhat-deploy
fixture、命名账户和 ethers 事件断言——它甚至在完整的端到端测试里监听
`WinnerPicked`。
Foundry 套件(`RaffleTest.t.sol`)是纯 Solidity:它 prank
coordinator mock,用
`vm.warp(block.timestamp + interval + 1)` 拨
时间、roll 区块,然后用同一种语言断言一切。两套测试独立地断言
相同的行为——hardhat 测试甚至检查
`consumers.includes(raffle.address)` 来证明订阅确实
连到了合约。
完整的开奖流程是端到端覆盖的:四个玩家入场、执行 `performUpkeep`、
从发出的日志里抓 `requestId`、经由 mock 完成 fulfill,然后断言赢家拿到
整个奖池、彩票回到 `OPEN`、时间戳前进了。
单元层周围是让「基于 mock 的测试」保持诚实的设施:一个 `LinkToken`
mock(ERC-677,带 `transferAndCall`)、Chainlink
`VRFCoordinatorV2_5Mock`,以及 `HelperConfig`,它按链构建
`NetworkConfig`。在 `LOCAL_CHAIN_ID`
(31337) 上它会自己部署 mock 并创建订阅;在 Sepolia 和主网上它持有
真实的 coordinator、gas lane 和 LINK 地址。两套工具链各有一份部署脚本:
`deploy/01-deploy-raffle.js`
(hardhat-deploy),以及 `script/DeployRaffle.s.sol`
(forge,配套 `Interactions.s.sol` 里的 `CreateSubscription`、
`FundSubscription`、`AddConsumer`)。
单元测试覆盖不了的东西,文件夹布局自己就承认了:integration 测试目录
是一份注释骨架,列着完整的金字塔——unit、integration、fork、staging、
fuzzing、formal verification。Mock 控制恰恰是你在真实网络上会失去的
东西,所以单元测试带着 `skipFork` modifier("Testnet cannot
test with Mock, don't have Mock control")——staging 阶段对着真实
Sepolia VRF 跑,练的是真正的订阅、资金和回调延迟,这些是 mock 只能
假装的东西。
最后,`.gas-snapshot` 把每个测试的成本钉死,回归被当作一个数字抓
出来,而不是一种感觉。完整的抽奖测试——从入场到付款的整条开奖流程
——是 335,011 gas:
```text
RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)
```
## 前端
仓库还带了一个纯 HTML 前端 `pages/1/`,它是笔记里列出的七种与合约
交互方式的第一种(HTML/JS,然后是 Next.js + 原生 ethers、web3-react、
react-moralis、web3Modal、useDapp、wagmi)。浏览器不能
`require("ethers")`,所以页面被 browserify 打包成 bundle:
```bash
yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js
```
`ProperCatch` 这个文件名本身就是重点:要修的就是
错误处理。每一个异步交互——connect、store、retrieve——都包在
`try/catch` 里,把错误 log 出来而不是让 promise 无处理地 reject;每条
路径都先检查 `typeof window.ethereum !== "undefined"`,钱包缺失时把
按钮文字换成 "Please install MetaMask"。旁边的 `index.js` 旧版本里一个
`try/catch` 都没有:交易被拒绝时 promise 只会无处理地在控制台里 reject,
而且它直接 `new ethers.providers.Web3Provider(window.ethereum)`,前面没有
任何判断。它是个小页面,但它决定了 demo
在交易被拒绝时是无声死掉,还是告诉你发生了什么。
## 重做时我会改什么
这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。
运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会
消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值——
我会写一个小监控脚本,给订阅补仓、余额太低时报警。
合约内部我会改四件事。第一,用 `randomWords[0] % s_players.length` 选
赢家在数组长度不能整除 2^256 时有模偏差——玩家少时可忽略,但真彩票
应该用拒绝采样,或者多要几个词。第二,回调忽略了 `requestId`——
`CALCULATING` 锁让重放不现实,但我会存下未完成的请求,并在
`fulfillRandomWords` 里校验。第三,赢家拿走全部余额,运营者一分不赚;
真彩票需要内置手续费或所有者分成。第四,没有暂停或紧急停止——一旦
出 bug,资金要卡到下一次开奖。
## 结果
一个数字,来自 gas snapshot:完整的开奖——四人入场、upkeep 触发、
VRF mock fulfill、赢家收款——在 Foundry 套件里跑 **335,011 gas**,
hardhat 套件又把同一个端到端行为断言了一遍。合约已部署并验证在
Sepolia 上(JS 部署的 0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4,
hardhat 部署的 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd)。我开头的
问题有了一个能指给人看的答案:随机数来自合约无法预测或重掷的模块,
状态机在开奖进行时锁住彩票,而自动化决定何时开奖。
我是全栈 Web 开发者和 DevOps 工程师,我构建完整的应用——
前端、后端、部署,以及那些必须持续跑下去的部分。如果你需要
一个全栈 Web 项目,或想让现有项目被可靠地部署,来找我:[WhatsApp](https://wa.me/60127972969)
· [[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com)。
@@ -0,0 +1,347 @@
---
title: "全链上 SVG NFT:把艺术作品放进合约里"
description: "如何用 Foundry 铸造全链上 SVG NFT:把 SVG 图片 base64 编码进合约,tokenURI() 动态生成 data URI 元数据,并从链上状态切换表情。"
pubDate: 2024-10-08
updatedDate: 2026-09-29
category: web3
tags: [solidity, foundry, erc721, nft, svg, base64, onchain]
ogImage: /og/fully-on-chain-svg-nfts.png
banner: /banners/fully-on-chain-svg-nfts.png
draft: false
---
每个 NFT 都有两半。代币本身 —— 余额、授权、所有权 —— 住在合约里,
和链一样永久。艺术是另一回事:`tokenURI()` 返回一个字符串,而大多数
集合里,这个字符串指向*别处*:一个 `ipfs://` CID,一个 HTTPS URL。
代币是永久的,它指向的东西,却是一个没有任何合约能控制的外部依赖。
这是一个学习项目,不是生产代码 —— README 里写得很明白 —— 而且这里的
一切都跑在 Sepolia 测试网上。我把它写下来,是因为全链上这个模式确实
有用,下面的数字都是真实的,代码也小到可以一口气读完。
## 如何铸造一个艺术作品永不消失的 NFT?
如果你是个刚学 Solidity 的新手,拿这个问题去搜,大部分答案都会把你
引向 IPFS,再配一句关于 pinning 的 shrug。对小尺寸的艺术品,有更好的
答案:把艺术*放进合约里*,编码好,然后给钱包一个完整的 `data:` URI,
让元数据 JSON 和图片都在同一个字符串里。这样一来,艺术作品就是链上
的字节,和代币本身没有区别。
做法有两种。你可以把原始 SVG 存进合约,每次调用 `tokenURI()` 时才做
base64 编码 —— 部署更便宜,每次读取稍微多花点 gas。或者,你可以在
部署时一次性编码,存下成品 `data:image/svg+xml;base64,` 字符串。我的
`MoodNft` 用的是第二种,因为它还要根据链上状态在两张图之间切换,
而这一切正是**动态 NFT** 的最佳入门。
## 我先尝试的做法:一个只存 URI 的 ERC-721
起点是常规的 NFT 合约。我的叫 `BasicNft`:
```solidity
contract BasicNft is ERC721 {
error BasicNft__TokenUriNotFound();
uint256 private s_tokenCounter;
mapping(uint256 => string) private s_tokenIdToUri;
constructor() ERC721("Hoelee", "HOE") {
s_tokenCounter = 0;
}
function mintNft(string memory tokenUri) public {
s_tokenIdToUri[s_tokenCounter] = tokenUri;
_safeMint(msg.sender, s_tokenCounter);
s_tokenCounter = s_tokenCounter + 1;
}
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
revert BasicNft__TokenUriNotFound();
}
return s_tokenIdToUri[tokenId];
}
}
```
它刻意做得非常小。`mintNft(tokenUri)` 把你给的字符串按代币存起来,
`tokenURI` 原样返回;如果代币从未被铸造,就触发一个自定义错误
(比带字符串的 `require` 更省 gas,也自带文档)。我用自己的名字命名
集合,因为重点是光明正大地学习:`ERC721("Hoelee", "HOE")`。
这个合约对 URI 指向什么没有任何意见 —— OpenZeppelin 的 `ERC721` 基类
也没有 —— 所以艺术住在 URI 所在的地方。
## IPFS 托管艺术的诚实问题
对 `BasicNft` 来说,流程是标准的:把元数据 JSON 放到 IPFS 上,把它的
`ipfs://<CID>` 交给合约,让钱包和市场通过网关去解析。渲染能成功,依赖
两件事一直成立:**有人持续 pin 住那个 CID**,并且**某个网关持续提供
它**。这两件事都在任何合约的控制范围之外。pin 一旦掉了,代币还在 ——
只是再也没有艺术了。
我自己的铸造脚本就是这有多随意的最佳证据。`Interactions.s.sol` 里带了
四个示例 URI:两个原生的 `ipfs://` CID,两个
`https://gateway.pinata.cloud/ipfs/...` URL。
README 在这个话题上的原话是:优先用原生 CID 形式,这样资产不依赖任何
单一网关。原文是:
"so the asset resolves independent of any single gateway"
网关 URL 是彻头彻尾的单一故障点。原生 CID 好一点,但它仍然依赖某个 pin 存在。
而保存下来的铸造日志显示,`BasicNft` 的 0 号代币实际存进去的是:
```text
ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png
```
这个 CID 指向的是一个我自己都没在跑的 pin。我没法保证那个节点 —— 或者
现在 pin 着它的任何人 —— 一直在线。「代币是永久的」和「艺术只是一句
承诺」之间的这道裂缝,正是我想要消除的东西。
## 修复方案:部署时把艺术编码进合约
`MoodNft` 把艺术本身存进合约。编码在部署脚本里完成:它读取 `img/`
下的两个 SVG 文件,在构造函数被调用之前,就把每一个都变成
`data:image/svg+xml;base64,` URI:
```solidity
function run() external returns (MoodNft) {
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
vm.startBroadcast();
MoodNft moodNft = new MoodNft(imageUriSmile, imageUriSad);
vm.stopBroadcast();
return moodNft;
}
function svgToImageUri(
string memory svg
) public pure returns (string memory) {
string memory baseURL = "data:image/svg+xml;base64,";
string memory svgBase64Encoded = Base64.encode(
bytes(string(abi.encodePacked(svg)))
);
return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
```
两个细节很关键。`vm.readFile` 是 Foundry 的 cheatcode,在脚本运行期间
从磁盘读文件,所以合约里永远不会出现手写生成再粘贴进去的 base64 块
—— 链上的艺术可以被证明就是 `img/` 里的艺术。而 `Base64` 来自
OpenZeppelin 的 utils,我也不用自己写编码器。
构造函数随后把两个现成的 URI 存为状态:
```solidity
constructor(
string memory happySvgImageUri,
string memory sadSvgImageUri
) ERC721("MoodNft", "MN") {
s_tokenCounter = 0;
s_sadSvgImageUri = sadSvgImageUri;
s_happySvgImageUri = happySvgImageUri;
}
```
`mintNft()` 不收任何 URI:它 `_safeMint` 铸造,把新代币记为
`Mood.HAPPY`,存进 `mapping(uint256 => Mood)`,然后计数器加一。
艺术在部署那一刻就定下来了,而不是铸造的时候。
## tokenURI 动态构建整个 NFT
这就是模式的核心:
```solidity
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
string memory imageURI;
if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
imageURI = s_happySvgImageUri;
} else {
imageURI = s_sadSvgImageUri;
}
string memory tokenMetadata = string.concat(
'{"name":"',
name(), // You can add whatever name here
'", "description":"An NFT that reflects the mood of the owner, 100% on Chain!", ',
'"attributes": [{"trait_type": "moodiness", "value": 100}], "image":"',
imageURI,
'"}'
);
string memory tokenURIJson = string(
abi.encodePacked(
_baseURI(),
Base64.encode(
bytes(abi.encodePacked(tokenMetadata))
)
)
);
return tokenURIJson;
}
function _baseURI() internal pure override returns (string memory) {
return "data:application/json;base64,";
}
```
跟着走一遍它返回的东西:JSON 里的 `"image"` 字段本身就是一个
`data:image/svg+xml;base64,` URI —— 是艺术本身,不是指向艺术的指针。
然后整个元数据 JSON 再做一次 base64 编码,`_baseURI()` 在前面加上
`data:application/json;base64,`。于是 `tokenURI()` 返回一个完全
自包含的字符串。钱包解码它,就同时拿到名字、描述、属性和图片字节,
没有任何东西还需要去取。整条链路里没有 IPFS、没有 HTTPS、没有网关。
## flipMood:由链上状态驱动的动态 NFT
因为情绪就是*状态*,改变状态就改变了艺术:
```solidity
function flipMood(uint256 tokenId) public {
if (
getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender
) {
revert MoodNft__NotOwnerOfToken();
}
if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
s_tokenIdToMood[tokenId] = Mood.SAD;
} else {
s_tokenIdToMood[tokenId] = Mood.HAPPY;
}
}
```
门槛是持有者*或*被授权地址 —— `getApproved()` 来自 `ERC721`,免费获得
—— 其他人一律得到 `MoodNft__NotOwnerOfToken()`。翻转之后,同一个
token id 的 `tokenURI` 就开始返回另一张图。同一个代币,不同的面孔,
全部在链上。这是最小可能的动态 NFT,也是思考「由游戏状态或链上事件
驱动的艺术」时很好的起点。
## 测试教会我的 Solidity 细节
**Solidity 里不能用 `==` 比较两个 `string`。** Solidity 只能比较值
类型;`string` 是动态字节数组。惯用修法是比较哈希,我的测试文件甚至
用注释写明了这一点:
```solidity
// string is array of bytes can't directly compare
// we can compare: bool, uint256, address, bytes32
assert(
keccak256(abi.encodePacked(expectedName)) ==
keccak256(abi.encodePacked(actualName))
);
```
统一用哈希比较:
`keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b))`
这个模式在测试套件里到处都是,也是链上需要比较字符串时伸手就该拿的。
**测试分两层。** 数一遍仓库里的测试函数:四个测试合约,一共六个测试。
`MoodNftTest` 是纯单元测试:直接用常量 base64 URI 构造 `MoodNft`。
另外三个 —— `DeployMoodNftTest`、`BasicNftTest`、
`MoodNftIntegrationTest` —— 实例化部署脚本并调用
`deployer.run()`,走的都是真实路径:在真正的 `img/*.svg` 上执行
`vm.readFile`、编码、部署。也就是说,连「单元」部署测试都在拿真实的
艺术文件验证 base64 流水线。
**身份伪装是两个 cheatcode。** `makeAddr("HOELEE")` 是从标签推导出的
确定性假地址 —— 不需要管理任何密钥。`vm.prank(USER)` 让*下一次*调用
看起来来自那个地址。两者配合,不需要钱包就能走通正常路径:
```solidity
function testFlipTokenToSad() public {
vm.prank(USER);
moodNft.mintNft();
vm.prank(USER);
moodNft.flipMood(0);
assertEq(
keccak256(abi.encodePacked(moodNft.tokenURI(0))),
keccak256(abi.encodePacked(SAD_SVG_URI))
);
}
```
**铸造脚本应该找到合约,而不是硬编码地址。** `Interactions.s.sol` 用
`DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid)`,
它会读 `broadcast/` 日志,返回当前链上该合约最近一次部署的地址:
```solidity
address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
"BasicNft",
block.chainid
);
mintNftOnContract(mostRecentDeployed);
```
我自己的脚本里还留着注释掉的 Sepolia 和 Anvil 硬编码地址 —— 这正是
这个模式存在要消灭的习惯。地址要从部署日志里拿,否则总有一天你会
给一个过期的合约铸造。
## 如果重来,我会怎么做
**链上艺术要付部署 gas,而且是永久的。** 两张 SVG 的每一个字节都在
部署时一次性付清,然后以构造函数字符串的形式永远住在合约存储里。
这是一次性成本,但它是真实的,并且随艺术尺寸增长。
**能放进去的艺术有上限。** EIP-170 把合约代码限制在 24,576 字节以内,
艺术字节要和逻辑共用这个预算。笑脸和哭脸这种矢量 SVG 是理想选择 ——
小、清晰、可缩放。照片或 3D 模型永远塞不进去,硬塞也只是浪费 gas。
我的 `BasicNft` 部署返回 `4102 bytes of code`;
`MoodNft` 的字节码还要再叠上两张 SVG。
**不是每个项目都该这么做。** 如果艺术很大、集合需要可变元数据、或者
部署预算紧张,IPFS 或文件存储加一个*可设置的* base URI 才是务实的默认
选择 —— `ERC721` 的 `_baseURI()` 钩子让这个模式也很容易。该问的正确
问题是:*这份艺术必须在所有 pin 服务都消亡后依然存活吗?* 如果是,
而且它放得进合约,那就上链。否则,别为这些字节付钱。
**锁死 SVG 的行尾。** 仓库通过 `.gitattributes` 把
`img/*.svg` 固定为 LF,保证编码后的字节是确定的;在 Windows 上,
`core.autocrlf` 会悄悄注入 CRLF,改变编码后的艺术。这种「艺术就是
字节」的模式,对这种隐形 bug 毫不留情。
**从第一天起就用 DevOpsTools。** 对单机学习来说,注释里的硬编码地址
没关系;一旦有第二次部署,它们就是陷阱。
## 量化的结果
仓库里保存了一份部署日志,是 `BasicNft` 部署到 Sepolia 的记录,所以
下面这些数字和文件里完全一致:
| Item | Value |
|---|---|
| Chain | `11155111` |
| Constructor trace | `[868596] → new BasicNft` |
| Deployed bytecode | `4102 bytes of code` |
| Deployment tx | `993568 gas * 0.538650187 gwei` = `0.000535185588997216 ETH` |
| Block | `6522146` |
| Deployed at | `0x84F0Ee970BD49FCf1b8Cd637EF4e4755DBE74e0E`, auto-verified on Etherscan |
把存了 IPFS URI 的 0 号代币铸造出来,花了 `181874 gas` ——
`0.000186204671471742 ETH`。也就是说,在测试网上把 NFT 放上去几乎不
花钱;IPFS 依赖的代价是隐形的,直到某个 pin 死掉的那一天。
而 `MoodNft` 真正重要的结果根本不需要日志:在 MetaMask 里把部署好的
合约和 0 号代币作为 collectible 添加,艺术作品就直接从链上 base64 SVG
渲染出来 —— 没有 IPFS 网关、没有网络查找、没有任何需要维持在线的东西。
同一个代币,`flipMood` 一按就换脸。艺术唯一依赖的「服务器」就是
区块链本身,而任何一个节点都跑着一份完整的链。
## 需要这类工作吗?
我是一个全栈 Web 开发者和 DevOps 工程师。这类链上 demo 是我学习的地方,
但我为企业做的工作是全栈 Web 开发 —— 网站、Web 应用、API,以及它们
背后的自托管栈和部署流水线。如果你需要一个从零开始的项目,或者想
用一个小的链上概念验证来验证想法,告诉我你想做出什么:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Web%20development%20project) ·
[hoelee.com](https://hoelee.com)。
@@ -0,0 +1,259 @@
---
title: "那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁"
description: "在 REST + JPA 的读-改-写流程里,两个编辑同时保存同一条记录时,后保存者会静默覆盖先保存者的修改,而 API 两次都返回成功——JPA 的 @Version 乐观锁把这个静默丢失更新变成 HTTP 409 冲突:请求携带期望版本号,版本不符就拒绝写入,并提示客户端重新读取再试。"
pubDate: 2026-08-19
category: engineering
tags: [java, spring, jpa, hibernate, rest, testing]
ogImage: /og/jpa-version-field-lost-update.png
banner: /banners/jpa-version-field-lost-update.png
draft: false
---
两个人同时打开同一篇文章,两个人都改了,两个人都保存。其中一次
保存悄无声息地消失了——没有报错,没有警告,而且 API 对两次请求
都返回了 HTTP 200。
这就是丢失更新(lost update)问题,是读-改-写(read-modify-write)
API 里最安静的数据丢失 bug。我在做 Spring Boot 作品集 demo 时撞上
了它——这是一个 Spring Boot 3.5.16(Java 21)的 REST API,编辑器
从浏览器页面创建和更新文章。修法最终落在三处:JPA 实体上的一个
`@Version` 字段、请求契约里的一个字段、以及 API 层把它映射成
HTTP 409 Conflict 的一个异常。这篇文章就沿着真实代码走一遍这条路。
## 问题:一条编辑消失了,却谁都怪不上
为什么两个人保存同一条记录时,其中一个人的修改会消失?
跟着时间线走。编辑器 A 和编辑器 B 都拉取了同一篇文章。A 先保存:
行被更新,API 返回 200。B 稍后保存:行被*再次*更新,这次覆盖了
A 的文字,API 又返回 200。每个请求都成功;每个响应都告诉它的调用
方「你的保存就是这条记录的当前状态」——对其中一个人来说这是假话。
A 的编辑就这么没了,两个客户端都没有任何办法知道。
失败是静默的,因为它就是读-改-写流程的正常行为,不是异常。这正是
它在真实系统里存活的理由,也是为什么在要展示工程判断力的东西里,
值得把它修得干净。
## 我先试的方案:不带版本号的读-改-写,以及它为什么失败
我对更新流程的第一版草图是最直白的那种:`GET` 记录、编辑、
`PUT` 回去,只带新的标题和正文,什么都不带。
```text
GET /api/posts/{id} # 读取记录
PUT /api/posts/{id} # 写回新的标题和正文
```
服务端加载行、套用更新、返回 200。它无从知道在这一行数据上,客户端
在 `GET` 和 `PUT` 之间已经被别人动过——读取和写入是两个互不相干的
请求,契约里没有任何东西把它们连起来。最后写入的人赢,而两个写入
者都被告知成功。
在这里,200 比报错更糟。报错至少告诉输掉的那个客户端「出事了」,
给他们一个去查的理由。200 则告诉两个客户端「你们的保存就是记录
当前的状态」——对其中一个是撒谎。输掉的那次编辑现在哪儿都不存在:
屏幕上没有,数据库里也没有;而因为客户端相信自己保存成功了,
没有人会去找这份数据。
## 修法:一个 @Version 字段、一份契约、一个冲突
修法一共五小步,全部都在 demo 的源码里。
### 1. 实体随身携带一个版本列
```java
@Entity
@Table(name = "posts", indexes = @Index(name = "idx_posts_title", columnList = "title"))
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue
@UuidGenerator
private UUID id;
@Column(nullable = false, length = 160)
private String title;
@Column(nullable = false, length = 10_000)
private String body;
@Version
private long version;
// ...
}
```
`@Version` 告诉 Hibernate 把 `version` 当作乐观锁。它生成的每一条
`UPDATE` 都变成有条件的:
```sql
UPDATE posts
SET title = ?, body = ?, version = version + 1, ...
WHERE id = ? AND version = ?
```
如果这行数据的版本号已经和语句构造时的不一致,受影响行数为零,
Hibernate 就会拒绝这次 flush,而不是悄悄覆盖。实体从不用手自增
计数器——更新方法只碰 `title` 和 `body`;版本号跟着写入一起走。
### 2. 版本号进入 API 契约
锁如果客户端无法参与,就没有用,所以版本号在两个方向上都穿过
API。响应的 record 暴露它:
```java
public record PostResponse(UUID id, long authorId, String title, String body,
long version, Instant createdAt, Instant updatedAt) {
}
```
更新请求则要求把它带回来:
```java
public record UpdatePostRequest(
@NotBlank @Size(max = 160) String title,
@NotBlank @Size(max = 10_000) String body,
@NotNull @Min(0) Long version) {
}
```
`UpdatePostRequest` 旁边的学习注记说得很直白:
「the expected version is part of the update
contract, making optimistic locking visible to clients.」
(期望的版本号是更新契约的一部分,让乐观锁对客户端可见。)
### 3. 在拥有写事务的 service 里检查版本
```java
@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
if (post.getVersion() != request.version()) {
throw new PostVersionConflictException(postId);
}
post.update(request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
```
读取、比较、保存全部发生在一个 `@Transactional` 方法里——这正是让
检查诚实的那部分:版本号是和*写入那一刻*的行状态比较,而不是和
早前某个请求留下的快照比较。如果读取和写入分属不同事务,检查到的
就是这行数据已经离开的旧版本,整套机制就成了表演。`PostService`
的学习注记点名了这条规则:「service methods
centralize transaction boundaries, cache coherence, and
optimistic-locking rules.」(service 方法集中管理事务边界、缓存
一致性与乐观锁规则。)demo 还开着
`spring.jpa.open-in-view: false`,所以不会有残留的会话跨请求地
供给过期实体——行是在写入事务内部重新读取的。
显式的检查会确定性地挡掉过期请求。`@Version` 列在底层仍然有意义:
如果检查与 flush 之间恰好插进一次别的提交,那条有条件的 `UPDATE`
影响零行,Hibernate 照样拒绝这次写入。
### 4. 异常处理器把它映射成 409
异常本身带着客户端能直接照做的信息:
```java
public class PostVersionConflictException extends RuntimeException {
public PostVersionConflictException(UUID postId) {
super("Post %s has changed; fetch it again before retrying".formatted(postId));
}
}
```
错误边界把它转成正确的 HTTP 应答:
```java
@ExceptionHandler(PostVersionConflictException.class)
ProblemDetail handleConflict(PostVersionConflictException exception) {
return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
}
```
`HttpStatus.CONFLICT` 就是 HTTP 409,响应是一份 problem
document——一个 `type` URI、一个稳定的机器码
(`POST_VERSION_CONFLICT`)和一段人类可读的说明。demo 在配置里
开启了框架的 problem-details 支持
(`spring.mvc.problemdetails.enabled: true`),所以校验错误和所有
其他错误都共用同一种响应形状。
### 5. 整条链路,从头到尾
```text
过期的 PUT /api/posts/{id}
-> PostService.updatePost 在事务内部重新读取这一行
-> post.getVersion() != request.version()
-> PostVersionConflictException: "fetch it again before retrying"
-> ApiExceptionHandler.handleConflict
-> HTTP 409, ProblemDetail with code POST_VERSION_CONFLICT
-> 客户端知道自己的前提过期了
```
一个 `@Version` 字段、请求契约里的一个 `version`、一个异常、一个
handler 方法。Controller 什么都没改——`updatePost` 看起来还是普通
的 `PUT`。
## 为什么答案是冲突,而不是重试
当两个编辑器产出同一份记录的两种不同版本时,服务端无法知道哪一方
的意图才是被需要的。从各自作者的角度看,两次保存都正当;重放那条
过期的写入,只是重放同一次数据丢失。
诚实的结论就是 HTTP 对 409 的定义:请求与资源的当前状态冲突,
并且客户端被明确告知如何解决——「fetch it again before retrying」
(重新取一次再试)。输掉的客户端重新读取记录,看到另一位作者的
修改,由人来决定保留什么。静默覆盖变成了一处看得见的决策点——这
正是这件事的全部意义。
## 这个 demo 的其余部分证明了什么
版本字段只是一个小系统里的一层,而其余部分都能用同样的方式核实
——下面每一行都是仓库里的一个类或配置文件,不是一句口号:
| 层 | 做了什么 | 在哪 |
|---|---|---|
| 缓存 | 单篇文章读取使用有界 Caffeine 缓存(上限 500,TTL 10 分钟,都来自配置);读取用 `@Cacheable`、更新用 `@CachePut`、删除用 `@CacheEvict`;`GET /api/showcase/cache` 暴露请求数、命中数、未命中数和命中率 | `CacheConfig`、`CacheProperties`、`ShowcaseMetricsController` |
| 安全 | 无状态 HTTP Basic + BCrypt;页面与读 API 公开,所有写操作需要 `EDITOR` 角色;本地默认是专用的非机密 `demo-editor`/`changeit` 账户,可用环境变量覆盖 | `SecurityConfig`、`application.yml` |
| 错误 | 请求 record 在边界处校验;一个 `@RestControllerAdvice` 返回一致的 problem document 和机器码——`POST_NOT_FOUND`(404)、`POST_VERSION_CONFLICT`(409)、带字段错误表的 `VALIDATION_FAILED`(400) | `CreatePostRequest`、`UpdatePostRequest`、`ApiExceptionHandler` |
| 配置 | 本地 profile 使用内存 H2 数据库(PostgreSQL 模式),`ddl-auto: update` 并预置示例文章;`prod` 选择 PostgreSQL,所有凭据来自 `APP_*` 环境变量,`ddl-auto: validate` | `application.yml`、`application-prod.yml` |
| 测试 | 一个 `@SpringBootTest` 套件断言:渲染的 Thymeleaf 页面、未认证 401 对编辑器 201、`VALIDATION_FAILED` 问题响应、公开搜索,以及重复读取后的缓存命中 | `DemoApplicationIntegrationTest` |
## 如果做真东西,我会怎么改
一旦这不再是本地 demo,有两件事立刻要变。项目 README 里两件都写了,
值得作为我自己的判断再重复一遍。
第一,schema 管理。我会在任何时候把 `ddl-auto` 设为 `validate` 之前,
先加 Flyway 或 Liquibase 迁移。demo 默认 profile 用的是 `update`,
在临时数据库上很方便,养成习惯就很危险——生产 schema 应该是
有版本管理的代码,而不是启动时的副作用。
第二,密钥。本地编辑器账户是刻意的非机密 demo 账户,带默认凭据。
做真东西的话,我会把凭据放进托管的密钥存储(managed secret store),
拿不到凭据就拒绝启动,而不是回退到默认值。`prod` profile 已经做到
所有凭据来自环境变量、不提交任何密钥——这正是我想要的形态,
只是不要那些回退值。
## 结果
过期的更新现在返回 HTTP 409,机器码 `POST_VERSION_CONFLICT`,而不是
一个静静丢掉另一位作者修改的 200。丢失不再可能悄悄发生:失败是
响亮的、可操作的——「fetch it again before retrying」——并且集成
测试套件端到端地断言了访问与校验行为。
整个修法就是一个字段上的一个注解,加上让版本号成为契约一部分的
管道代码。如果一条记录可能同时被两个人读取,这个字段就是「编辑
丢失」与「双方都看得见的冲突」之间的分界线。
我平时就做 Java/Spring 后端和全栈 Web 应用——从浏览器页面到数据库
的 REST API:JPA、乐观锁、缓存、校验和一整套集成测试。如果你的表单
或字段里也有「编辑悄悄消失」的问题,跟我说说:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Spring%20Boot%20help) ·
[hoelee.com](https://hoelee.com)。
@@ -0,0 +1,239 @@
---
title: "逐页验证一份 19 页的 PDF 报告"
description: "如何逐页验证生成的 PDF 报告:用浏览器打印同一份 HTML 作基线,逐页比对几何尺寸,把每一处偏差都压到 2pt 以内。"
pubDate: 2026-09-28
updatedDate: 2026-09-29
category: engineering
tags: [php, codeigniter, pdf, css, print, testing]
ogImage: /og/verifying-a-pdf-report-page-by-page.png
banner: /banners/verifying-a-pdf-report-page-by-page.png
draft: false
---
你怎么知道一份生成出来的 PDF 每一页都真的正确——不只是第一页?
我维护着一个 CodeIgniter 4 应用:输入一个人的出生信息,生成一份
中文命理/生命密码报告——十九张密排的 A4 sheet,封面、数字图表、
方向九宫格、「一到九」的个人特征页。以前这份报告按网站原本的设计
交付:操作员在浏览器里按 Ctrl+P,手工另存成 PDF。我把它换成了
服务端渲染(mPDF),一夜之间,产品的质量取决于一个我看不见
如何排版的引擎。
这就是这个问题可被搜索的版本:如何把一份多页 PDF 报告逐页地、
用数字而不是肉眼,去和浏览器自己的打印输出对版?这个过程抓到
了真实的 bug——包括一行被渲染成 2.5pt、从第二页起几乎看不见的
页眉——最后全部十九页与浏览器基线的偏差都压进了 2pt。
## 为什么「看起来没问题」不算测试
没有人能用肉眼验证一份 19 页的报告。过去的流程是:打开 PDF,
扫一眼封面,交付。第 17 页标题漂移、页脚插图压住版权行、页眉
小到读不出来——快速翻一遍永远发现不了,而花钱买报告的顾客
看到的是全尺寸。
天真的做法会失败,是结构性的原因。服务端 PDF 渲染器不会像
浏览器那样排你的 HTML:分页、页边距、基线、字距全都会漂移——
而且不存在一个「看起来没问题」的测试,能让你在下周有人改了
CSS 之后再跑一次。我第一次单趟渲染出来是 27 页,而设计预期
是 19 页:mPDF 没有 CSS 裁剪,而 sheet 靠 `overflow: hidden`
藏住溢出。报告的 `@page` 规则更糟:mPDF 为这条规则本身
翻了一页,19 张 sheet 炸成 12,789 页。页数都不稳定,所以
「扫一眼第一页」不是验证——是碰运气。
## 基线:让浏览器去打印你的 HTML
操作员的 Ctrl+P,就是 Chrome 用打印 CSS 打印报告自己的 HTML。
所以参照标准不是「我觉得它应该长什么样」,而是浏览器自己的
打印输出——在同一台机器、同一套网页字体下生成。做法:把报告
目录用本地静态服务起起来(字体必须同源,否则 webfont 拒绝
加载),再用 headless Chrome 打印。报告 CSS 声明了
`@page { margin: 0 }`,所以浏览器输出是无边距满幅,再配合
`-webkit-print-color-adjust: exact` 保住背景图——等价于操作员
勾选「背景图形」:
```bash
python -m http.server 8123 --bind 127.0.0.1 --directory <report-dir>
"C:\Program Files\Google\Chrome\Application\chrome.exe" --headless=new --disable-gpu \
--no-pdf-header-footer --user-data-dir=%TEMP%\chromeprofile --virtual-time-budget=30000 \
--print-to-pdf=chrome-win.pdf "http://127.0.0.1:8123/report-local.html"
```
然后在 VM 上,用同一份 HTML 出服务端版本:
```bash
php tools/pdf-render.php /tmp/report-v10.html /tmp/mpdf.pdf
```
## 逐页比对,而不是整体对比
以浏览器的 PDF 为基准,我在 Windows 这边用
`uv run --with pymupdf` 逐页对比两份文档:页面尺寸、文字条数、
图片包围盒(x0/y0/宽/高)、附图说明文字的 y 坐标。判定阈值:
**偏差 ≤2pt(0.7mm)算对齐**;超过 5pt 就要查根因。而且只比
「同一元素在两份 PDF 里的差」——永远不要比绝对页数,因为页数
一致是前提,不是测试本身。
## 逐页比对抓到了什么
下面每个 bug 都有可测量的前后数字,而且都修在库或模板里——
不是用管道糊过去。
**看不见的页眉行。** 从第二页起每页顶部有一行小字,用户直接
反馈说小到读不出来。页眉是一个 `font-size: 10px` 的 div,
里面套一张 auto 宽的 table,第一格写着 `width: 100%`。mPDF
把这解读成「表格超宽」,于是连同字号一起把整行缩到三分之一:
**2.5pt,而 Chrome 是 7.5pt**。修法(`fixPageHeaderTables()`):
给表格显式宽度 + 显式字号(px→pt),并去掉第一格的
`width: 100%`:
```php
$pt = round((float) $m[2] * 0.75, 2); // 10px = 7.5pt
```
**匹配过宽的外边距规则。** `.sheet { margin: 5mm auto }` 是为
屏幕预览准备的(sheet 之间的阴影缝隙)。mPDF 当真了,把
296mm 的 sheet 推到 301mm——越过 297mm 的页面——于是 sheet
在页边被切开,auto-fit 把整页缩小 3%,带白边。最直观的修法、
在样式表末尾追加一条 `.sheet { margin: 0; }`,完全没用:mPDF
对同名选择器的两条规则只认「先出现的赢」。所以库要原地改写
这条规则(`stripSheetMargins()`),而匹配器必须小心什么才算
「sheet 规则」。护栏是一个负向回顾断言:
```php
'~(?<![\w.\-])\.sheet\s*\{([^}]*)\}~i'
```
只匹配独立的 `.sheet` 规则——像 `.invoice-sheet` 这种只是名字
以 `-sheet` 结尾的选择器不会被碰,剪边距的逻辑就毁不掉无关
规则。(收据文档就是独立的单页文档,下面会讲。)
**页脚插图偏了最多 490pt。** sheet 用 `position: absolute;
bottom: Npx` 把插图钉在页底。mPDF 只在文档顶层认绝对定位,
所以进了 sheet 之后这些图片退回普通流:**高了 30–490pt**
(第 6 页偏了 213.5pt,也就是 75mm),而且**窄了 10%**
(450pt vs Chrome 的 499.5pt)——因为百分比宽度是按 sheet
的 189mm 内容盒算的,而 Chrome 按 210mm 的包含块算。修法:
把所有钉底的图片从 sheet 里抽出来,放进 mPDF 自己的 HTML
footer(`SetHTMLFooter()`)——按页锚定、不占正文流,几何
统一按页面盒换算。结果:**≤2pt**。「窄 10%」也一起消失了,
因为两者同一个根因。
**22 处居中内容全部左对齐。** 模板用旧式 `<center>` 标签
居中,而 mPDF 8 的 `Center` 标签处理器是空类——标签整个被
丢弃,所有居中的标题和表格全部左对齐(「前言」实测 x=30,
Chrome 是 x=280)。`expandCenterTags()` 把 `<center>` 改写成
`<div style="text-align:center">`,并给居中块里的表格补上
`align="center"`——因为父级的 `text-align` 传不到表格。
修复后:x=281,Chrome 280。
**行距比浏览器高 15%。** 模板的 normalize.css 声明了
`html { line-height: 1.15 }`;mPDF 不继承它,退回自己的字体
度量(1.33)。这累积成每页下半部 20–40pt 的漂移——sheet 3
甚至溢出到第二页,触发整页缩小。把 `useFixedNormalLineHeight`
设成模板自己的值之后,正文行距 15.5pt,Chrome 是 15.7。
表格还要显式加 `td, th { padding: 1px }`——mPDF 默认的
单元格内边距比浏览器大 2px。
**字体是错的。** mPDF 读不了网页用的 `.woff`,文字回退到
自带的 Sun-ExtA;而正文栈里的「微软雅黑」在 Linux 服务器上
根本不存在。我注册了三套真 TTF——标题手写体 MaShanZheng、
拉丁与数字 Roboto、正文 CJK 用 wqy-microhei——并把模板的
字体栈映射过去。一个坑:必须关掉 mPDF 的自动按脚本选字,
否则它挑「第一个支持中文的已注册字体」——整页正文都变成
手写体。
**还有一个与 mPDF 无关的 bug。** 第 7 页的主插图在**两份**
PDF 里都是破图——比对显示 Chrome 和 mPDF 里是同一个破损
占位块。模板硬编码了 `https://cdn.hoelee.com/...`,这个域名
已经不再解析(NXDOMAIN),所以每个引擎都抓不到图。把模板
改回应用自己的 base URL,并让 `localiseAssets()` 把任何
host 的 `/static/` 路径都映射到本地文件,两个引擎里的插图
都恢复了。只有并排比对才能暴露这一类 bug——单独看哪个引擎
都「正常」。
## 一张 sheet,一页
渲染器这么设计是有意的:库把 HTML 按 `<section class="sheet">`
切开,每张 sheet 单独渲染成一份单页文档,合并时只取第 1 页——
物理上保证「一张 sheet = 正好一页 A4」,永远不会跨页断裂。
mPDF 量中文宽度和 Chrome 略有不同,个别 sheet 会高出几毫米。
与其丢内容,库按一把缩放梯子——1.0, 1.005, 1.01, 1.02, 1.03,
1.06, 1.10, 1.15, 1.22——取第一个能落进一页的比例,缩小整张
sheet 而不是裁内容。隐藏溢出是浏览器打印做的事
(`overflow: hidden`);一份收费产品如果 PDF 里静默丢了页内
内容,就是无声的交付事故,所以设计选择是绝不丢内容。sheet 3
现在需要 x1.005——0.5%,肉眼不可见——以前是 x1.03。
**「19/7 页」是什么意思。** 报告模板永远按固定顺序渲染十九张
sheet;「版本」是这些 sheet 上的一个过滤器,不是第二份模板。
完整版是 **19 页**;RM49 的精华版是**这 19 张里的 7 张**,
页码重编,每张入选的 sheet 都登记了一个文字标记——模板被
改动/重排导致取错页时会大声失败,而不是把错误的章节交给
顾客。两个版本走同一条管线,也用同一种方式验收:
`tools/pdf-verify.php --expect=19` 和
`--expect=7` 双双 PASS
——页数加逐页 ink 检查(ghostscript 50dpi)证明没有任何
空白页。
## 为什么收据是独立文档
收据不是报告裁剪出来的。它是自己的一份单页 A4 文档:真
16mm 页边距(报告刻意做满幅无边距)、三语、单趟渲染——
因为里面没有 sheet。它在发「已收款」邮件时才懒生成:付款
回调必须毫秒级应答,0.3–1 秒的 PDF 渲染不属于回调。它落进
同一个交付存储,文件名带 `-receipt` 后缀,永远不会和报告
文件撞名,而且幂等:重试、重寄、顾客自己来拿,拿到的都是
同一份。做它的过程从另一个方向印证了同一个论点——连单页
文档都和浏览器不一样:`<small>` 上的 `display: block`
不生效,左右并排的两张表把右列的数值裁出页面右边界,
合计行必须写在明细表内部,否则标签会浮在半空。
## 我会怎么做不一样
对版方法现在躺在项目笔记里,是一份文档化的流程,不是仓库
里的脚本——这就是差距。仓库里自动化的验收工具只证明页数和
逐页 ink,永远抓不到 2.5pt 的页眉或 15% 的行距漂移。我会把
浏览器基线比对做成仓库验证工具链里的一个脚本:用同一份
HTML 分别喂 Chrome 和库,diff 几何,任何超过 5pt 的偏差
直接失败。这样,一次让打印布局悄悄回退的 CSS 改动会在构建
时炸掉,而不是送到顾客手里。
我还会在写任何 mPDF 补偿代码之前就先出 Chrome 基线。
「用操作员用的同一个引擎打印,然后测量」才是解锁点;之后
每个修复都是机械活。还有两件诚实的遗留工作:伴侣合盘与
家庭套餐还没跑过这套逐页对版,19 页的 PDF(背景图加嵌入
字体约 20 MB)也还需要在交付前压缩。
## 结果
修复后的完整版验收:
```text
$ php tools/pdf-verify.php /tmp/report-v10.html --expect=19
out : 20,225,818 bytes, 19 pages, 10.4s, peak 188 MB
per-page ink check: all pages have content | report pages=19
expected 19 pages => MATCH
RESULT: PASS
```
- **19 页 vs Chrome 基线的 19 页 — MATCH**,一张 sheet 一页,
无跨页断裂。
- 每一项实测偏差都**在 2pt(0.7mm)以内**:第 6 页偏了
213.5pt 的页脚插图、从 2.5pt 恢复到浏览器同款 7.5pt 的
页眉、归位的居中标题、15.5pt vs Chrome 15.7 的正文行距、
与网页字体一致的嵌入字体。
- ink 检查确认**没有任何空白页**,PDF 文字可提取——封面能
读出真实文字,这对一份顾客要复制内容的报告很重要。
- 精华版:**7 页 PASS**,4.99 MB,2.8 秒。
这就是「第一页看起来没问题」和「全部十九页都在浏览器两个点
以内」的区别。前者是肉眼扫一遍就交付的结果;后者是拿浏览器
自己当测试得到的结果。
---
我平时就做这类 Web 应用与打印/PDF 报告管线,也做网站设计与
开发。如果你有一份服务器渲染的文档——报告、收据、发票——
想在它送到顾客手里之前确认每一页都正确,跟我说说:
[WhatsApp](https://wa.me/60127972969) · [[email protected]](mailto:[email protected]?subject=PDF%20report%20pipeline) ·
[hoelee.com](https://hoelee.com)。
@@ -0,0 +1,159 @@
---
title: "为什么在 Windows 上克隆后,我的链上 NFT 图像变了"
description: "为什么我在 Windows 上克隆后链上 NFT 的图像变了:core.autocrlf 往 vm.readFile 读取的 SVG 里注入 CRLF,base64 编码随之改变,而一个 eol=lf 规则就修好了它。"
pubDate: 2026-08-19
category: web3
tags: [foundry, solidity, svg, base64, git, windows, crlf]
ogImage: /og/why-my-on-chain-nft-art-changed-on-windows.png
banner: /banners/why-my-on-chain-nft-art-changed-on-windows.png
draft: false
---
一个完全链上的 NFT,其作品本该是永久的。图像不是某个随时可能被
下架的 URL —— 它是一段 base64 字符串,在部署时写入合约存储,
之后永远无法改变。所以这个问题几乎是自己冒出来的:这样的作品
怎么会变?而且还是静默地变?
在我这个例子里,答案是:作品从来就不是一串固定的字符。它取决于
部署脚本在**执行部署的那台机器**上恰好从磁盘读到的字节 —— 而在
Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。
这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」
NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
两者都编码在合约自身中。图像 URI 在部署时由 `img/` 目录里的源
文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条
坏掉的换行符,如何差点让这幅作品变成「因机器而异」。
## 我遇到的事:部署脚本编码的是「读到的字节」
部署脚本用几行就干完了全部活:
```solidity
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
```
`vm.readFile` 返回一个字符串,`Base64.encode` 把这**一模一样的
字节**变成 `data:image/svg+xml;base64,...` URI,构造函数把两个
URI 永久存入存储。「链上」在这里是字面意思:部署机器上文件当时的
字节,如今就是合约数据 —— 永久地。部署时 SVG 哪怕差一个字节,
就是另一幅作品,在合约存续期内焊死不动。
我担心的这个差异来自 Git 的 `core.autocrlf`。Windows 上的 Git
安装通常会把人工作区里的文本文件改写成 CRLF 换行,即使仓库里存
的是 LF。SVG 是文本文件。CRLF 和 LF 是不同的字节,而 base64 对
不同字节的编码也不同。两行命令就能证明:
```bash
printf 'a\nb' | base64 # YQpi
printf 'a\r\nb' | base64 # YQ0KYg==
```
一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于:
没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。
`git status` 依然干净,因为 Git 在比较文本时先做了换行归一化。
Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何
机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于
执行部署的是哪台机器。
## 修法:.gitattributes 里的一条规则
修法是一个文件、一条规则,而那段注释同样重要:
```gitattributes
# Force LF line endings for asset files read by forge scripts (vm.readFile)
# so the working tree always matches what's stored in git, regardless of
# core.autocrlf / Windows checkout behavior.
img/*.svg text eol=lf
```
为什么有效:`text` 告诉 Git 把这些文件当作文本并做归一化,所以
仓库里它们永远以 LF 存储。`eol=lf` 则把这些人路径的工作区检出
钉死在 LF 上,覆盖任何机器上的 `core.autocrlf` 设置。两个属性
合在一起意味着:在一台 `core.autocrlf=true` 的 Windows 机器上,
`img/*.svg` 依然以 LF 检出 —— 所以 `vm.readFile` 永远返回作者
提交时的那组字节,base64 URI 跨平台可复现。
说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的
归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的,
规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能
发生的分歧。
同一个 commit 还修了第二件静默出错的东西:`foundry.toml` 里
`remappings` 的一个拼写错误。这条映射决定了
`@openzeppelin/contracts/...` 的导入如何解析到子模块,所以一个
错字就会让构建失败,而报错和我写的代码毫无关系:
```toml
remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]
```
## 紧挨着的两个坑
### 坑一:没有 fs_permissions,vm.readFile 拒绝执行
`vm.readFile` 是一个 *fs cheatcode* —— 除非
在 `foundry.toml` 里显式授权路径,否则 Foundry
不允许脚本触碰文件系统:
```toml
fs_permissions = [
{ access = "read", path = "./img/" },
{ access = "read", path = "./broadcast" },
]
```
对 `./img/` 的读权限是为了 SVG;`./broadcast` 是为了让铸造脚本
里的 DevOpsTools 助手能找到最近的部署日志。没有授权,部署会在
第一次读取时就失败 —— 这又是一种近乎静默的失败,因为报错指向
cheatcode,而不是你的代码。
### 坑二:DevOpsTools 需要 ffi = true
交互脚本会导入 `foundry-devops` 里的 DevOpsTools 来定位上一次
部署,而不是硬编码一个地址。这个导入需要启用 Foundry 的 `ffi`
cheatcode,所以配置里带着它,并附了一句说明注释:
```toml
ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol
```
`ffi` 是货真价实的权限授予 —— 它让脚本可以运行任意 shell 命令
—— 所以那句注释是应得的;在开启它之前,值得先弄清楚究竟是哪个
导入需要它。
## 如果重来,我会怎么做
两个习惯能更早抓住这个问题,也能抓住下一条换行符回归:
1. **在测试里断言编码后的字节。** 集成测试已经会跑真正的部署
脚本,框架是现成的。加一个单元测试,断言
`vm.readFile("img/smile.svg")` —— 或者最终的图像 URI —— 等于
预期的 LF 编码 base64 字符串,那么 CRLF 回归就会让 `forge test`
大声失败,而不是悄悄把另一幅作品送上链。
2. **凡是脚本按字节读取的资源目录,都钉上 `eol=lf`。** 这个坑
不只属于 SVG。如果脚本要内嵌 JSON 元数据或任何其他文本资源,
同样的事情照样发生。经验法则:任何用 fs cheatcode 读的东西,
在脚本提交之前,先给它配一条 `.gitattributes` 规则。
## 结果
修复之后,base64 载荷在 Windows 检出和 Linux 检出上完全一致 ——
同一串字符串,在两台机器上各自算一遍,逐字节相等 —— 所以部署
出来的合约作品是可复现的,而不是因机器而异的。这正是链上作品的
全部意义所在;而它差一点就被一个文件、一条规则、一次 `printf`
就能证明的问题悄悄毁掉。
坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。
但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。
我平时做全栈网站开发 —— 前端、后端、自托管部署 —— 而这一类
字节级、跨平台的排障,正是真正上线软件时会遇到的事。如果你的
项目需要一个不只盯 diff、还盯着字节的开发者,跟我说说:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com)。