diff --git a/docs/project-state.md b/docs/project-state.md
index 2581f6f..a2b31ef 100644
--- a/docs/project-state.md
+++ b/docs/project-state.md
@@ -283,7 +283,40 @@ moved into the archive's empty months with `updatedDate: 2026-09-29` holding the
something already written → floor 2026-09-13.
- **Still empty, and therefore the spare slots for the next batch:** 2024-10 → 2025-02 (five months) and 2025-04/05.
- **Done when:** ✅ build clean, `lastmod` = 2026-09-29 for all four URLs (EN + ZH), listing order still monotonic
- on `/posts/`, the homepage and `/zh/`, both article pages render the historical date.
+on `/posts/`, the homepage and `/zh/`, both article pages render the historical date.
+
+**Step B2i — (unplanned) The web3 category, plus two engineering posts.** ✅ Done 2026-09-29
+`web3` had **zero** posts and no route at all: `/categories/` rendered its card with the label
+"0 posts · coming soon" as a *non-link*, and `/categories/web3/` returned **404** (Astro only emits a
+category detail route once the category has posts). A Gitea sweep found seven real web3 repos whose
+commits date to **2024-08-15 → 2024-08-19**, which is also why those posts could be backdated honestly.
+Five posts shipped (EN + ZH, custom OG + banner, hire CTA):
+
+| Slug | Category | pubDate | updatedDate | Source repo |
+|---|---|---|---|---|
+| `fully-on-chain-svg-nfts` | `web3` | 2024-10-08 | 2026-09-29 | `foundry-nft` — `MoodNft.sol`, `DeployMoodNft.s.sol` |
+| `why-my-on-chain-nft-art-changed-on-windows` | `web3` | 2026-08-19 | — | `foundry-nft` — the `.gitattributes` fix commit |
+| `chainlink-vrf-v2-lottery-contract` | `web3` | 2024-12-10 | 2026-09-29 | `hardhat-smartcontract-lottery` — `Raffle.sol` |
+| `verifying-a-pdf-report-page-by-page` | `engineering` | 2026-09-28 | 2026-09-29 | `numerology-report` — `docs/pdf-pipeline.md` |
+| `jpa-version-field-lost-update` | `engineering` | 2026-08-19 | — | `springboot-hoelee-demo` — `@Version` |
+
+- **Why these dates:** the two 2024 posts fill the empty 2024-10 and 2024-12 archive months with the real
+ work date and `updatedDate` holding the true date, so `lastmod` stays honest. The two 2026-08-19 posts use
+ the real work date rather than joining the 2026-09-29 pile-up. `verifying-a-pdf-report-page-by-page` was
+ moved **one day** to 2026-09-28 for the same reason — it was the fifth post landing on 2026-09-29.
+- ⚠ **Date pins were re-checked before moving any date.** The only `202[0-9]` hits in the two backdated
+ posts are inside the contract address `0xc2022b56…`, not dates. All cited figures were traced to source:
+ `868596` / `4102 bytes` / `993568` gas / `0.000535185588997216 ETH` / block `6522146` / mint `181874`
+ (deploy + mint logs), `335011` gas (`.gas-snapshot`), `2.5pt→7.5pt` + `20,225,818` bytes + `30–490pt`
+ (`docs/pdf-pipeline.md`; the `27 pages` / `12,789 pages` figures live in `app/Libraries/ReportPdf.php`
+ lines 21 and 196, **not** in the doc), `@Version` + `POST_VERSION_CONFLICT` in the Spring demo.
+- ⚠ **The `TERMINALS` / `BANNERS` entries for all five slugs ARE committed this time** (unlike B2f, which had
+ to hide them in this file because a parallel session held uncommitted generator edits). Both generators
+ were verified clean and the diffs purely additive (30/0 and 100/0) before editing.
+- Banner centering **measured, not eyeballed**: all five at 8 rows, `gapAbove`/`gapBelow` within 2px,
+ `overflow=0`, `scrollHeight == clientHeight == 636`.
+- **Done when:** ✅ 2 new category routes (`/categories/web3/`, `/zh/categories/web3/`), build 127 pages clean,
+ all 10 post URLs + 10 images 200, language switch both ways, listing order monotonic on `/posts/`, `/`, `/zh/`.
### Phase C — Discovery & structure (Tier 2)
diff --git a/public/banners/chainlink-vrf-v2-lottery-contract.png b/public/banners/chainlink-vrf-v2-lottery-contract.png
new file mode 100644
index 0000000..0fc0246
Binary files /dev/null and b/public/banners/chainlink-vrf-v2-lottery-contract.png differ
diff --git a/public/banners/fully-on-chain-svg-nfts.png b/public/banners/fully-on-chain-svg-nfts.png
new file mode 100644
index 0000000..18b91b9
Binary files /dev/null and b/public/banners/fully-on-chain-svg-nfts.png differ
diff --git a/public/banners/jpa-version-field-lost-update.png b/public/banners/jpa-version-field-lost-update.png
new file mode 100644
index 0000000..d128e34
Binary files /dev/null and b/public/banners/jpa-version-field-lost-update.png differ
diff --git a/public/banners/verifying-a-pdf-report-page-by-page.png b/public/banners/verifying-a-pdf-report-page-by-page.png
new file mode 100644
index 0000000..4299c50
Binary files /dev/null and b/public/banners/verifying-a-pdf-report-page-by-page.png differ
diff --git a/public/banners/why-my-on-chain-nft-art-changed-on-windows.png b/public/banners/why-my-on-chain-nft-art-changed-on-windows.png
new file mode 100644
index 0000000..1b35730
Binary files /dev/null and b/public/banners/why-my-on-chain-nft-art-changed-on-windows.png differ
diff --git a/public/og/chainlink-vrf-v2-lottery-contract.png b/public/og/chainlink-vrf-v2-lottery-contract.png
new file mode 100644
index 0000000..10bd4bd
Binary files /dev/null and b/public/og/chainlink-vrf-v2-lottery-contract.png differ
diff --git a/public/og/fully-on-chain-svg-nfts.png b/public/og/fully-on-chain-svg-nfts.png
new file mode 100644
index 0000000..a104612
Binary files /dev/null and b/public/og/fully-on-chain-svg-nfts.png differ
diff --git a/public/og/jpa-version-field-lost-update.png b/public/og/jpa-version-field-lost-update.png
new file mode 100644
index 0000000..c3f4f91
Binary files /dev/null and b/public/og/jpa-version-field-lost-update.png differ
diff --git a/public/og/verifying-a-pdf-report-page-by-page.png b/public/og/verifying-a-pdf-report-page-by-page.png
new file mode 100644
index 0000000..6777161
Binary files /dev/null and b/public/og/verifying-a-pdf-report-page-by-page.png differ
diff --git a/public/og/why-my-on-chain-nft-art-changed-on-windows.png b/public/og/why-my-on-chain-nft-art-changed-on-windows.png
new file mode 100644
index 0000000..acb4d28
Binary files /dev/null and b/public/og/why-my-on-chain-nft-art-changed-on-windows.png differ
diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs
index d84c6ab..0b45f78 100644
--- a/scripts/banner-gen/generate.mjs
+++ b/scripts/banner-gen/generate.mjs
@@ -929,6 +929,106 @@ BANNERS['reading-a-containers-own-api-docs'] = {
],
};
+BANNERS['fully-on-chain-svg-nfts'] = {
+ titlebar: 'foundry — sepolia · the art lives in the contract',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'forge script script/DeployMoodNft.s.sol --broadcast' },
+ { t: 'prompt', text: 'IPFS' }, { t: 'err', text: 'BasicNft stores a URI — art depends on a pin + a gateway' },
+ { t: 'prompt', text: 'ON-CHAIN' }, { t: 'cmd', text: 'Base64.encode(vm.readFile("img/smile.svg")) at deploy' },
+ { t: 'prompt', text: 'DEPLOY' }, { t: 'ok', text: 'BasicNft@0x84F0… · 4102 bytes of code · 868,596 gas' },
+ { t: 'prompt', text: 'MINT' }, { t: 'cmd', text: 'mintNft() → 181,874 gas · tokenId 0 belongs to the minter' },
+ { t: 'prompt', text: 'FLIP' }, { t: 'err', text: 'flipMood(0) by a non-owner → MoodNft__NotOwnerOfToken' },
+ { t: 'prompt', text: 'FLIP' }, { t: 'ok', text: 'owner flips → HAPPY ⇄ SAD, straight from on-chain state' },
+ { t: 'prompt', text: '' }, { t: 'hl', text: 'metadata + art inside the contract · no gateway lookup' },
+ ],
+ flow: [
+ { n: '1', label: 'draw the SVG' },
+ { n: '2', label: 'base64 at deploy' },
+ { n: '3', label: 'data: URI metadata' },
+ { n: '4', label: 'owner flips mood ✓' },
+ ],
+};
+
+BANNERS['why-my-on-chain-nft-art-changed-on-windows'] = {
+ titlebar: 'windows — one byte rewrites the artwork',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'vm.readFile("img/smile.svg") → Base64.encode' },
+ { t: 'prompt', text: 'WIN' }, { t: 'err', text: 'core.autocrlf rewrites the SVG with CRLF on checkout' },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: "printf 'a\\nb' | base64 → YQpi" },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: "printf 'a\\r\\nb' | base64 → YQ0KYg==" },
+ { t: 'prompt', text: 'SILENT' }, { t: 'err', text: 'editor identical · git status clean · forge never warns' },
+ { t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'img/*.svg text eol=lf in .gitattributes' },
+ { t: 'prompt', text: 'SAME' }, { t: 'ok', text: 'Windows and Linux checkouts encode identical bytes' },
+ { t: 'prompt', text: '' }, { t: 'hl', text: 'assert the encoded URI in a test — art stays reproducible ✓' },
+ ],
+ flow: [
+ { n: '1', label: 'read the bytes' },
+ { n: '2', label: 'CRLF injected' },
+ { n: '3', label: 'base64 differs' },
+ { n: '4', label: 'eol=lf pinned ✓' },
+ ],
+};
+
+BANNERS['chainlink-vrf-v2-lottery-contract'] = {
+ titlebar: 'anvil — raffle · vrf v2.5 + automation',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'forge test --match-contract RaffleTest' },
+ { t: 'prompt', text: 'VRF' }, { t: 'err', text: 'InsufficientBalance() — the mock subscription held no funds' },
+ { t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'fundSubscription + addConsumer before the tests run' },
+ { t: 'prompt', text: 'KEEP' }, { t: 'cmd', text: 'checkUpkeep → true once the interval has elapsed' },
+ { t: 'prompt', text: 'DRAW' }, { t: 'cmd', text: 'performUpkeep → requestRandomWords · state = CALCULATING' },
+ { t: 'prompt', text: 'VRF' }, { t: 'ok', text: 'fulfillRandomWords → randomWords[0] % players.length' },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'forge snapshot → the full draw costs 335,011 gas' },
+ { t: 'prompt', text: '' }, { t: 'hl', text: 'testnet learning project · not production money-handling code' },
+ ],
+ flow: [
+ { n: '1', label: 'pay to enter' },
+ { n: '2', label: 'timer trips upkeep' },
+ { n: '3', label: 'VRF returns proof' },
+ { n: '4', label: 'winner paid ✓' },
+ ],
+};
+
+BANNERS['verifying-a-pdf-report-page-by-page'] = {
+ titlebar: 'dsm — mPDF vs the browser print',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'php tools/pdf-verify.php /tmp/report-v10.html --expect=19' },
+ { t: 'prompt', text: 'BUG' }, { t: 'err', text: 'page header rendered at 2.5pt — invisible past page 1' },
+ { t: 'prompt', text: 'BUG' }, { t: 'err', text: 'stripSheetMargins also clipped .invoice-sheet' },
+ { t: 'prompt', text: 'BUG' }, { t: 'err', text: 'footer art off by 30–490pt — absolute only honoured at top level' },
+ { t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'per-sheet render · match only a standalone .sheet rule' },
+ { t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'hoistPinnedArt() + SetHTMLFooter() for the pinned artwork' },
+ { t: 'prompt', text: 'PASS' }, { t: 'ok', text: '19/19 pages MATCH · every deviation ≤ 2pt' },
+ { t: 'prompt', text: '' }, { t: 'hl', text: '20,225,818 bytes · 19 pages · 10.4s · essence 7 pages PASS' },
+ ],
+ flow: [
+ { n: '1', label: 'render per sheet' },
+ { n: '2', label: 'print baseline' },
+ { n: '3', label: 'diff geometry' },
+ { n: '4', label: '≤ 2pt ✓' },
+ ],
+};
+
+BANNERS['jpa-version-field-lost-update'] = {
+ titlebar: 'spring boot — two editors, one row',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET /api/posts/1 → { id: 1, version: 3 }' },
+ { t: 'prompt', text: 'A' }, { t: 'ok', text: 'PUT version 3 → 200 · the row is now version 4' },
+ { t: 'prompt', text: 'B' }, { t: 'err', text: 'PUT version 3 → would overwrite A and still answer 200' },
+ { t: 'prompt', text: 'FIX' }, { t: 'cmd', text: '@Version on the entity → UPDATE … WHERE version = 3' },
+ { t: 'prompt', text: 'JPA' }, { t: 'err', text: 'ObjectOptimisticLockingFailureException' },
+ { t: 'prompt', text: 'API' }, { t: 'ok', text: 'PostVersionConflictException → 409 Conflict' },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: './mvnw test → integration test asserts the conflict path' },
+ { t: 'prompt', text: '' }, { t: 'hl', text: 'stale writes fail loudly · the client re-reads instead of clobbering' },
+ ],
+ flow: [
+ { n: '1', label: 'read v3' },
+ { n: '2', label: 'A saves' },
+ { n: '3', label: 'B saves stale' },
+ { n: '4', label: '409 ✓' },
+ ],
+};
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs
index 53021c4..46db8eb 100644
--- a/scripts/og-gen/generate.mjs
+++ b/scripts/og-gen/generate.mjs
@@ -286,6 +286,36 @@ TERMINALS['reading-a-containers-own-api-docs'] = `
$grep process.env → AUTH_SECRET · PORT 3000 · WORKER_TIMEOUT
$exec cat /proc/net/tcp→ listening on 3000 ✓
`;
+TERMINALS['fully-on-chain-svg-nfts'] = `
+ $forge script script/DeployMoodNft.s.sol --broadcast
+ tokenURI → ipfs://… · art depends on a pin and a gateway
+ Base64.encode(vm.readFile("img/smile.svg"))
+ → data:application/json;base64,… · the whole NFT on chain ✓
`;
+
+TERMINALS['why-my-on-chain-nft-art-changed-on-windows'] = `
+ $vm.readFile("img/smile.svg") → Base64.encode
+ core.autocrlf rewrote the SVG with CRLF in the working tree
+ printf 'a\\nb' | base64 ≠ printf 'a\\r\\nb' | base64
+ img/*.svg text eol=lf→ same bytes on every machine ✓
`;
+
+TERMINALS['chainlink-vrf-v2-lottery-contract'] = `
+ $forge test --match-contract RaffleTest
+ InsufficientBalance() · the VRF mock had no subscription balance
+ fundSubscription + addConsumer in setUp()
+ → draw picks a winner · 335,011 gas ✓
`;
+
+TERMINALS['verifying-a-pdf-report-page-by-page'] = `
+ $php tools/pdf-verify.php /tmp/report-v10.html --expect=19
+ page header rendered at 2.5pt · footer art off by 30–490pt
+ per-sheet render + diff against the browser's own print
+ → 19/19 PASS · every deviation ≤ 2pt ✓
`;
+
+TERMINALS['jpa-version-field-lost-update'] = `
+ $PUT /api/posts/1 { "version": 3, "title": … }
+ row is already at version 4 · the write would win silently
+ @Version → UPDATE … WHERE version = 3
+ → 409 Conflict, not a lost update ✓
`;
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
diff --git a/src/content/posts/chainlink-vrf-v2-lottery-contract.md b/src/content/posts/chainlink-vrf-v2-lottery-contract.md
new file mode 100644
index 0000000..156496b
--- /dev/null
+++ b/src/content/posts/chainlink-vrf-v2-lottery-contract.md
@@ -0,0 +1,338 @@
+---
+title: "An Automated Lottery Contract on Chainlink VRF v2.5 and Automation"
+description: "Building a lottery on Chainlink VRF v2.5 and Automation: why the draw can't be rigged, a full contract walkthrough, and the mock-funding bug that broke it."
+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
+---
+
+Can you run a lottery on-chain that nobody can rig — not the players, not the
+miners, and not even the person who deployed it? That was the question I was
+trying to answer when I built `Raffle.sol`, and it is the reason this project
+uses a blockchain oracle at all.
+
+The short answer is yes, with two honest conditions. The randomness has to
+come from somewhere the contract itself cannot predict or reroll, and the
+draw has to happen without a human pressing a button — because a human with
+a button can choose *when* to draw, and "when" is already an attack. This
+post walks through the contract, the bug that broke the draw, how I test it,
+and where I would do things differently. It is a learning project, not
+production money-handling code — I say that up front because the post's
+credibility depends on it.
+
+## Why this is one of the few genuinely good uses of an oracle
+
+A contract cannot produce its own randomness. `blockhash` of the current
+block is predictable, `block.timestamp` is chosen by whoever mines the
+block, and any pure-Solidity "random" function is deterministic — every
+player can recompute it. An on-chain lottery therefore needs an oracle, and
+this is one of the few cases where an oracle is not a weakness but the whole
+point.
+
+Chainlink VRF (Verifiable Randomness Function) returns a random number with a
+proof that the contract verifies on-chain. The contract cannot predict the
+number before it arrives and cannot reroll it after — the number is committed
+before the draw, and the module that produced it cannot see who entered. That
+is exactly the property a lottery needs and the property no hash of the block
+can give you.
+
+The second half is Chainlink Automation. Nodes watch the contract and call
+`checkUpkeep` on a timer; when it says "yes, draw now", they call
+`performUpkeep`. The contract's own docstring describes the goal better than I
+can:
+
+```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)
+```
+
+No keeper nodes, no human — the contract answers the question "should a winner
+be drawn right now?" and the oracle acts on the answer.
+
+## What I tried, and the bug that broke the draw
+
+The project is `hardhat-smartcontract-lottery`: one `Raffle` contract, tested
+with both Hardhat and Foundry, deployed and verified on Sepolia. The setup
+was the usual VRF v2.5 dance: create a subscription at vrf.chain.link, fund
+it, deploy the consumer, add it to the subscription, then register an
+Automation upkeep on a 30-second interval (the interval, the 0.01 ETH
+entrance fee, and the 500,000-gas callback limit all live in `HelperConfig`).
+
+The draw broke, and invisibly from inside the contract. Locally, the VRF mock
+accepted the request without complaint — then the fulfillment reverted. The
+repo's own history records the failure — the fix landed in a commit message
+that literally reads "Fixed InsufficientBalance VRF Mock" — and the traces
+are still in the test files. Next to the `fulfillRandomWords` calls in the
+Foundry tests there is a comment that just says `// InsuficientBalance()`,
+and the hardhat test has a longer, sadder one:
+`// Here cannot run, always InsufficientBalance()`.
+
+What I had not understood is that the mock is not laxer than mainnet — it
+enforces the same invariant. Before it will serve a request, the subscription
+must exist, hold a balance, and have the raffle registered as a consumer, and
+the LINK has to actually move. The mock tracks the subscription's balance the
+way the coordinator does — the JS test reads `getSubscription(...).balance`
+back to confirm it — and on a real chain your wallet sends LINK to the
+coordinator with `transferAndCall` so the subscription is credited. My
+subscription was either empty or the consumer was not yet attached, and the
+fulfillment legitimately bounced.
+
+The fix lives in the test setup: the Foundry `setUp()` mints 100 LINK and
+funds the subscription *before any test runs* (`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();
+```
+
+The deploy scripts fund real chains the same way — `FundSubscription` sends
+`FUND_AMOUNT` (3 LINK) with `transferAndCall`:
+
+```solidity
+LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));
+```
+
+The hardhat deploy funds the subscription it just created. The lesson cost
+me an evening: **a VRF mock needs a funded subscription before it will serve a
+request, and checkUpkeep's own docstring says it implicitly — "Implicity, your
+subscription is funded with LINK."** Randomness is not free, even in a mock.
+
+## The fix, walked through the contract
+
+`Raffle` inherits from two Chainlink contracts, both from the v2.5 line:
+
+```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 {
+```
+
+The constructor takes the coordinator address, the subscription id, the gas
+lane (key hash), the interval, the entrance fee, and the callback gas limit,
+all `immutable` except the state that must change. Two constants matter:
+`REQUEST_CONFIRMATIONS = 3` and `NUM_WORDS = 1` — the draw needs one random
+word, confirmed three blocks.
+
+**Entering.** `enterRaffle` is deliberately boring: pay >= the fee or revert
+`Raffle__NotEnoughETHEntered`, and be in the `OPEN` state or revert
+`Raffle__RaffleNotOpen`. Then push the sender and emit an event:
+
+```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);
+```
+
+**Deciding whether to draw.** `checkUpkeep` is the whole Automation
+contract in one line:
+
+```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);
+```
+
+**Drawing.** `performUpkeep` is only callable by the network, but it re-checks
+`checkUpkeep` anyway — an upkeep can be triggered with stale data, and defense
+in depth costs nothing. If the check fails it reverts with a custom error that
+embeds the evidence:
+
+```solidity
+revert Raffle__UpkeepNotNeeded(
+ address(this).balance,
+ s_players.length,
+ uint256(s_raffleState)
+);
+```
+
+The error data itself tells you which condition failed. The state then flips
+and the VRF request goes out:
+
+```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` means the request is paid in LINK from the subscription
+— which is why the funding bug mattered.
+
+**The lock.** `RaffleState` is an enum, `OPEN` and `CALCULATING`. From the
+moment `performUpkeep` flips it until `fulfillRandomWords` resets it, the
+raffle is `CALCULATING`, and `enterRaffle` reverts. Nobody can sneak in
+between the request and the callback, so the array the randomness indexes is
+exactly the set of players the randomness was drawn against. This is the
+whole rig-proofing story in one enum.
+
+**Picking the winner.** The VRF coordinator calls back into
+`fulfillRandomWords`, which the contract overrides:
+
+```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();
+}
+```
+
+The order is deliberate and it is the Checks-Effects-Interactions pattern,
+which the contract names in its own comment. The winner, the player array, the
+timestamp, and the raffle state are all reset **before** the ETH transfer. If
+the winner turns out to be a contract whose fallback tries to re-enter the
+raffle, there is nothing left to re-enter against — the state is already fresh
+and the transfer failure is handled by a custom error, not a silent `require`
+string.
+
+## How it is tested — a hybrid suite, on purpose
+
+The repo keeps two unit suites for the same contract, because they catch
+different things. The Hardhat/JS suite (`Raffle.test.js`) uses hardhat-deploy
+fixtures, named accounts, and ethers event assertions — it even listens for
+`WinnerPicked` in the full end-to-end test. The Foundry suite
+(`RaffleTest.t.sol`) is native Solidity: it pranks the coordinator mock, warps
+time with `vm.warp(block.timestamp + interval + 1)`, rolls blocks, and asserts
+everything in one language. Both suites assert the same behavior
+independently — the hardhat test even checks
+`consumers.includes(raffle.address)` to prove the subscription is wired.
+
+The full draw is covered end to end: enter four players, run
+`performUpkeep`, grab the `requestId` from the emitted log, fulfill it
+through the mock, then assert the winner got the whole pot, the raffle is
+`OPEN` again, and the timestamp moved.
+
+Around the unit layer sits the infrastructure that makes a mock-based suite
+honest: a `LinkToken` mock (ERC-677, with `transferAndCall`), the
+Chainlink `VRFCoordinatorV2_5Mock`, and `HelperConfig`, which builds a
+per-chain `NetworkConfig`. On `LOCAL_CHAIN_ID` (31337) it deploys the mocks
+and creates a subscription itself; on Sepolia and mainnet it holds the real
+coordinator, gas lane, and LINK addresses. Deploy scripts exist for both
+`deploy/01-deploy-raffle.js` (hardhat-deploy) and `script/DeployRaffle.s.sol`
+(forge, with `CreateSubscription` / `FundSubscription` / `AddConsumer` in
+`Interactions.s.sol`).
+
+What unit tests cannot cover is acknowledged by the folder layout: the
+integration tests directory is a comment skeleton naming the full pyramid —
+unit, integration, fork, staging, fuzzing, formal verification. Mock control
+is exactly what you lose on a real network, so the unit tests carry the
+`skipFork` modifier ("Testnet cannot test with Mock, don't have Mock
+control") — staging runs against real Sepolia VRF exercise the real
+subscription, funding, and callback latency that a mock can only fake.
+
+Finally, `.gas-snapshot` pins the cost of every test so a regression is caught
+as a number, not a feeling. The full winner-selection test — the entire draw,
+from entrance through payment — is 335,011 gas:
+
+```text
+RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
+RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
+RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)
+```
+
+## The front end
+
+The repo also carries a plain-HTML front end, `pages/1/`, which is method
+one of seven the notes list for talking to a contract (HTML/JS, then Next.js
+with raw ethers, web3-react, react-moralis, web3Modal, useDapp, wagmi). The
+browser cannot `require("ethers")`, so the page is browserified into a
+bundle:
+
+```bash
+yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js
+```
+
+The error-handling page is the `ProperCatch` variant, and the name is the
+point: error handling was the thing being fixed. Every async interaction —
+connect, store, retrieve — is wrapped in `try/catch` that logs the error
+instead of letting the promise reject unhandled, and every path checks
+`typeof window.ethereum !== "undefined"` first, flipping the button text to
+"Please install MetaMask" when the wallet is missing.
+The earlier `index.js` sitting next to it has no `try/catch` at all, so a
+rejected transaction promise simply goes unhandled in the console, and it
+builds `new ethers.providers.Web3Provider(window.ethereum)` with no guard in
+front of it. It is a small page, but it is the difference between a demo that
+dies silently on a rejected transaction and one that tells you what happened.
+
+## What I'd do differently
+
+This is a testnet learning project and I want the limits on the record: no
+audit, no economic attack modeling, no mainnet money. The README calls it "a
+learning project note" and that is exactly what it is.
+
+Operationally, a real lottery has an ongoing cost that a learning project
+doesn't: the VRF subscription drains LINK with every request, and the
+Automation upkeep needs its own LINK balance. Nobody funds that automatically
+— I'd build a small watch script that tops up the subscription and alerts
+when balances run low.
+
+In the contract itself I would change four things. First, picking the winner
+with `randomWords[0] % s_players.length` has modulo bias whenever the array
+length does not divide 2^256 — negligible at small player counts, but a real
+lottery should use rejection sampling or more than one word. Second, the
+callback ignores `requestId` — the `CALCULATING` lock makes a replay
+impractical, but I would store the outstanding request and verify it in
+`fulfillRandomWords`. Third, the winner takes the entire balance, so the
+operator earns nothing; a real lottery needs a fee or owner share built in.
+Fourth, there is no pause or emergency stop — a bug would leave funds
+stuck until the next draw.
+
+## The result
+
+One number, from the gas snapshot: the complete draw — four players entering,
+the upkeep firing, the VRF mock fulfilling, the winner paid — runs at
+**335,011 gas** in the Foundry suite, and the hardhat suite asserts the
+same end-to-end behavior a second time. The contract is deployed and
+verified on Sepolia (0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4 via the JS
+deploy and 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd via hardhat deploy),
+and the question I started with has an answer I can point at: the random
+number comes from a module the contract cannot predict or reroll, the state
+machine locks the raffle while the draw is in flight, and the automation
+decides when.
+
+I'm a full-stack web developer and DevOps engineer, and I build complete
+applications — front end, back end, deployment, the parts that have to keep
+running. If you need a full-stack web project built or an existing one
+reliably deployed, get in touch: [WhatsApp](https://wa.me/60127972969) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Full-stack%20web%20development) ·
+[hoelee.com](https://hoelee.com).
\ No newline at end of file
diff --git a/src/content/posts/fully-on-chain-svg-nfts.md b/src/content/posts/fully-on-chain-svg-nfts.md
new file mode 100644
index 0000000..907ed46
--- /dev/null
+++ b/src/content/posts/fully-on-chain-svg-nfts.md
@@ -0,0 +1,368 @@
+---
+title: "Fully On-Chain SVG NFTs: Putting the Artwork Inside the Contract"
+description: "How to mint fully on-chain SVG NFTs with Foundry: base64-embed the artwork in the contract, build data URI metadata in tokenURI(), and flip moods on-chain."
+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
+---
+
+Every NFT has two halves. The token itself — balances, approvals, ownership —
+lives in the contract and is as permanent as the chain. The artwork is a
+different story. `tokenURI()` returns a string, and for most collections that
+string points *somewhere else*: an `ipfs://` CID, an HTTPS URL. The token is
+permanent. The thing it points to is a dependency nobody's contract controls.
+
+This project is a learning repo, not production work — the README says exactly
+that — and everything here ran against the Sepolia testnet. I am writing it
+up because the fully on-chain pattern is genuinely useful, the numbers below
+are real, and the code is small enough to read in one sitting.
+
+## How do you mint an NFT whose artwork cannot disappear?
+
+If you search that question as a new Solidity developer, most answers route
+you to IPFS with a shrug about pinning. There is a better answer for small
+art: put the art *in* the contract, encoded, and hand the wallet a complete
+`data:` URI that contains the metadata JSON and the image in one string. Then
+the artwork is just bytes on the chain, no different from the token itself.
+
+There are two ways to do it. You can store the raw SVG in the contract and
+base64-encode it every time `tokenURI()` is called — cheaper to deploy, a
+little more gas per read. Or you can encode the SVG once at deploy time and
+store the finished `data:image/svg+xml;base64,` strings. My `MoodNft` does
+the second, because it also flips between two images from on-chain state, and
+the whole thing is a gentle introduction to **dynamic NFTs**.
+
+## What I tried first: an ERC-721 that stores a URI
+
+The starting point is the conventional NFT contract. Mine is `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];
+ }
+}
+```
+
+It is deliberately tiny. `mintNft(tokenUri)` stores whatever string you
+hand it, per token; `tokenURI` returns that string; a custom error fires
+if the token was never minted (cheaper than a string `require`, and
+self-documenting).
+I named the collection after myself, because the point was learning with a
+straight face: `ERC721("Hoelee", "HOE")`.
+
+The contract has no opinion about what the URI points to — neither does
+OpenZeppelin's `ERC721` base — so the art lives wherever the URI lives.
+
+## The honest problem with IPFS-hosted art
+
+For `BasicNft` the flow is the standard one: put a metadata JSON on IPFS,
+hand the contract its `ipfs://`, and let wallets and marketplaces
+resolve it through a gateway. Rendering works as long as two things stay
+true: **someone keeps a pin of that CID alive**, and **some gateway keeps
+serving it**. Both are outside anyone's contract. If the pin drops, the token
+still exists — it just has no art.
+
+My own mint script is exhibit A of how sloppy this gets. `Interactions.s.sol`
+carries four example URIs: two raw `ipfs://` CIDs and two
+`https://gateway.pinata.cloud/ipfs/...` URLs. The README's own note on the
+topic says to prefer the raw CID form "so the asset resolves independent of
+any single gateway." A gateway URL is a single point of failure, full stop.
+The raw CID is better, but it still depends on a pin existing somewhere.
+
+And the saved mint log shows what actually got stored for token 0 of
+`BasicNft`:
+
+```text
+ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png
+```
+
+That CID is a pointer to a pin I did not run. I cannot promise that node —
+or whoever pins it now — stays up. That gap between "the token is permanent"
+and "the art is a promise" is exactly what I wanted to remove.
+
+## The fix: encode the artwork at deploy time
+
+`MoodNft` stores the art itself. The deploy script does the encoding, reading
+the two SVG files from `img/` and turning each into a
+`data:image/svg+xml;base64,` URI before the constructor is even called:
+
+```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));
+}
+```
+
+Two details matter. `vm.readFile` is a Foundry cheatcode that reads a file
+from disk during the script run, so the contract never contains a base64 blob
+that was hand-generated and pasted — the art on chain is provably the art in
+`img/`. And `Base64` comes from OpenZeppelin's utils, so I am not writing an
+encoder myself.
+
+The constructor then stores both ready-made URIs as state:
+
+```solidity
+constructor(
+ string memory happySvgImageUri,
+ string memory sadSvgImageUri
+) ERC721("MoodNft", "MN") {
+ s_tokenCounter = 0;
+ s_sadSvgImageUri = sadSvgImageUri;
+ s_happySvgImageUri = happySvgImageUri;
+}
+```
+
+`mintNft()` takes no URI at all: it `_safeMint`s, records the new token as
+`Mood.HAPPY` in a `mapping(uint256 => Mood)`, and increments a counter. The
+art was decided at deploy time, not at mint time.
+
+## tokenURI builds the whole NFT on the fly
+
+This is the heart of the pattern:
+
+```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,";
+}
+```
+
+Walk through what that returns: the `"image"` field inside the JSON is
+itself a `data:image/svg+xml;base64,` URI — the art, not a pointer to art.
+The whole metadata JSON is then base64-encoded, and `_baseURI()` prepends
+`data:application/json;base64,`, so `tokenURI()` returns one self-contained
+string. A wallet decodes it and has the name, the description, the attributes
+and the image bytes with nothing left to fetch. There is no IPFS, no HTTPS,
+no gateway in the entire chain of custody.
+
+## flipMood: a dynamic NFT from on-chain state
+
+Because the mood is *state*, changing state changes the art:
+
+```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;
+ }
+}
+```
+
+The gate is owner *or* approved address — `getApproved()` comes free from
+`ERC721` — and anyone else gets `MoodNft__NotOwnerOfToken()`. Flip it, and
+`tokenURI` starts returning the other image for the same token id. Same
+token, different face, all of it on chain. That is the smallest possible
+dynamic NFT, and a good place to start thinking about art driven by
+game state or on-chain events.
+
+## Solidity details the tests taught me
+
+**You cannot `==` two `string`s in Solidity.** Solidity only compares value
+types; `string` is a dynamic array of bytes. The idiomatic fix is comparing
+hashes, and my test file even documents it in a comment:
+
+```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))` appears
+all over the test suite, and it is the pattern to reach for whenever you must
+compare strings on-chain.
+
+**The test layout is two layers.** Counting the test functions in the repo
+gives six tests across four test contracts. `MoodNftTest` is a pure unit
+test: it constructs `MoodNft` directly with constant base64 URIs. The other
+three — `DeployMoodNftTest`, `BasicNftTest`, and `MoodNftIntegrationTest` —
+instantiate the deploy script and call `deployer.run()`, so they exercise the
+real path: `vm.readFile` on the actual `img/*.svg`, encode, deploy. That
+means even the "unit" deploy test is proving the base64 pipeline against the
+real art files.
+
+**Impersonation is two cheatcodes.** `makeAddr("HOELEE")` is a
+deterministic fake address derived from a label — no keypair to manage.
+`vm.prank(USER)` makes the *next* call look like it comes from that
+address. Together they drive the happy path without a wallet:
+
+```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))
+ );
+}
+```
+
+**Mint scripts should find the contract, not hardcode it.** The
+`Interactions.s.sol` mint script uses
+`DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid)`,
+which reads the `broadcast/` logs and returns the latest deployment of
+that contract on the current chain:
+
+```solidity
+address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
+ "BasicNft",
+ block.chainid
+);
+mintNftOnContract(mostRecentDeployed);
+```
+
+My own script still carries the old hardcoded Sepolia and Anvil addresses in
+comments — evidence of the habit this pattern exists to kill. Get the address
+from the deployment logs, or you *will* mint a stale contract someday.
+
+## What I'd do differently
+
+**On-chain art costs deploy gas, forever.** Every byte of both SVGs is paid
+for once, at deploy time, and then lives in the contract's storage as
+constructor strings. It is a one-time cost, but it is real, and it scales
+with the size of the art.
+
+**There is an upper bound on how much art fits.** EIP-170 caps a contract's
+code at 24,576 bytes, and the art bytes share that budget with the logic.
+Vector SVGs like the smiley and the sad face are ideal — small, crisp, and
+they scale. A photograph or a 3D model will never fit, and trying to cram
+realistic art in there is a waste of gas. My `BasicNft` deploy returned
+`4102 bytes of code`; the `MoodNft` bytecode has two SVGs stacked on top of
+that.
+
+**Not every project should do this.** If the art is large, if the collection
+needs mutable metadata, or if deploy budget matters, IPFS or a file store
+with a *settable* base URI is the pragmatic default — and `ERC721`'s
+`_baseURI()` hook makes that pattern easy too. The right question to ask is:
+*must this art survive the death of every pinning service?* If yes and it
+fits on chain, go on-chain. Otherwise, don't pay for the bytes.
+
+**Pin the SVG line endings.** The repo pins `img/*.svg` to LF via
+`.gitattributes` so the encoded bytes are deterministic; on Windows,
+`core.autocrlf` would otherwise inject CRLF and silently change the encoded
+art. That is exactly the kind of invisible bug that this pattern — "the art
+is the bytes" — makes unforgiving.
+
+**Use DevOpsTools from day one.** Hardcoded addresses in comments are fine
+for a solo testnet learner; they are a trap the moment a second deploy
+happens.
+
+## The quantified result
+
+The repo keeps one deployment log, and it is for `BasicNft` on Sepolia, so
+these figures are exactly as recorded:
+
+| 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 |
+
+The mint that stored the IPFS URI for token 0 cost `181874 gas` —
+`0.000186204671471742 ETH`. So the price of putting an NFT on a testnet is
+tiny; the price of the IPFS dependency is invisible until the pin dies.
+
+And the result that matters for `MoodNft` needs no log at all: add the
+deployed contract and token ID `0` as a collectible in MetaMask, and the
+artwork renders straight from the on-chain base64 SVG — no IPFS gateway, no
+network lookup, nothing to keep alive. For the same token id, `flipMood`
+changes the face. The only "server" the art depends on is the blockchain
+itself, and a full copy of that runs on any node.
+
+## Want this kind of work?
+
+I am a full-stack web developer and DevOps engineer. On-chain demos like
+this one are where I learn, but the work I do for businesses is full-stack
+web development — websites, web apps, APIs, and the self-hosted stacks and
+deploy pipelines behind them. If you need a project built from scratch, or a
+small on-chain proof of concept to validate an idea, tell me what you are
+trying to ship: [WhatsApp](https://wa.me/60127972969) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Web%20development%20project) ·
+[hoelee.com](https://hoelee.com).
\ No newline at end of file
diff --git a/src/content/posts/jpa-version-field-lost-update.md b/src/content/posts/jpa-version-field-lost-update.md
new file mode 100644
index 0000000..5be50fe
--- /dev/null
+++ b/src/content/posts/jpa-version-field-lost-update.md
@@ -0,0 +1,277 @@
+---
+title: "The JPA Field That Turns a Silent Lost Update Into a 409"
+description: "How a JPA @Version field turns a silent lost update into a 409 conflict — optimistic locking makes a stale REST write fail loudly instead of overwriting."
+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
+---
+
+Two people open the same post. Both edit it. Both save. One of the saves
+disappears without a trace — no error, no warning, and the API answered
+HTTP 200 for both of them.
+
+That is the lost update problem, and it is the quietest data-loss bug in
+read-modify-write APIs. I hit it while building my Spring Boot portfolio
+demo — a Spring Boot 3.5.16 (Java 21) REST API where editors create and
+update posts from a browser page. The fix turned out to be one `@Version`
+field on the JPA entity, one field on the request contract, and one
+exception that the API layer maps to HTTP 409 Conflict. This post traces
+that path through the actual code.
+
+## The problem: an edit that vanishes with no one to blame
+
+Why does one editor's change disappear when two people save the same
+record?
+
+Follow the timeline. Editor A and editor B both fetch the same post.
+A saves first: the row is updated and the API answers 200. B saves a
+moment later: the row is updated *again*, this time over A's text, and
+the API answers 200 again. Every request succeeded; every response told
+its caller that their save is the state of the record. A's edit is
+simply gone, and neither client has any way to know.
+
+The failure is silent because it is normal read-modify-write behaviour,
+not an anomaly. That is exactly why the bug survives in real systems, and
+why it is worth fixing properly in something meant to demonstrate
+engineering judgement.
+
+## What I tried first, and why it failed
+
+My first sketch of the update flow was the obvious one: `GET` the record,
+edit it, `PUT` it back with the new title and body and nothing else.
+
+```text
+GET /api/posts/{id} # read the record
+PUT /api/posts/{id} # write it back with a new title and body
+```
+
+The server loads the row, applies the update, and returns 200. It has no
+way to notice that the row moved underneath the client between the `GET`
+and the `PUT` — the read and the write are two unrelated requests, and
+nothing in the contract connects them. The last write wins, and both
+writers are told they succeeded.
+
+A 200 is worse than an error here. An error at least tells the losing
+client that something happened and gives them a reason to look. A 200
+tells both clients that their save is the current state of the record,
+which is a lie for one of them. The losing edit now exists nowhere — not
+on screen, not in the database — and because the client believes it
+saved, nobody goes looking for the data.
+
+## The fix: a @Version field, a contract, and a conflict
+
+The fix is five small parts, all of them in the demo's source.
+
+### 1. The entity carries a version column
+
+```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` tells Hibernate to treat `version` as an optimistic lock. Every
+`UPDATE` it generates becomes conditional:
+
+```sql
+UPDATE posts
+SET title = ?, body = ?, version = version + 1, ...
+WHERE id = ? AND version = ?
+```
+
+If the row's version no longer matches the value the statement was built
+with, zero rows change and Hibernate rejects the flush instead of quietly
+overwriting. The entity never bumps the counter by hand — the update
+method only touches `title` and `body`; the version moves with the write
+itself.
+
+### 2. The version is part of the API contract
+
+A lock is useless if the client cannot take part in it, so the version
+travels through the API in both directions. The response record exposes
+it:
+
+```java
+public record PostResponse(UUID id, long authorId, String title, String body,
+ long version, Instant createdAt, Instant updatedAt) {
+}
+```
+
+and the update request requires it back:
+
+```java
+public record UpdatePostRequest(
+ @NotBlank @Size(max = 160) String title,
+ @NotBlank @Size(max = 10_000) String body,
+ @NotNull @Min(0) Long version) {
+}
+```
+
+The learning note next to `UpdatePostRequest` says it plainly: "the
+expected version is part of the update contract, making optimistic
+locking visible to clients."
+
+### 3. The service checks it inside the transaction that owns the write
+
+```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));
+}
+```
+
+The load, the comparison and the save all happen inside one
+`@Transactional` method, and that is the part that makes the check
+honest: the version is compared against the row as it stands *at write
+time*, not against a snapshot from an earlier request. If the read and
+the write lived in different transactions, the check would compare
+against a version the row has already left behind, and the whole scheme
+would be theatre. The learning note on `PostService` names the rule:
+"service methods centralize transaction boundaries, cache coherence, and
+optimistic-locking rules." The demo also runs with
+`spring.jpa.open-in-view: false`, so no lingering session serves a stale
+entity across requests — the row is re-read inside the writing
+transaction.
+
+The explicit check catches stale requests deterministically. The
+`@Version` column still matters underneath: if another commit slips in
+between the check and the flush, the conditional `UPDATE` affects zero
+rows and Hibernate refuses the write anyway.
+
+### 4. The handler maps the exception to a 409
+
+The exception itself carries the message a client can act on:
+
+```java
+public class PostVersionConflictException extends RuntimeException {
+
+ public PostVersionConflictException(UUID postId) {
+ super("Post %s has changed; fetch it again before retrying".formatted(postId));
+ }
+}
+```
+
+and the error boundary turns it into the right HTTP answer:
+
+```java
+@ExceptionHandler(PostVersionConflictException.class)
+ProblemDetail handleConflict(PostVersionConflictException exception) {
+ return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
+}
+```
+
+`HttpStatus.CONFLICT` is HTTP 409, and the response is a problem document
+— a `type` URI, a stable machine code (`POST_VERSION_CONFLICT`) and a
+human-readable detail. The demo enables the framework's problem-details
+support in its configuration (`spring.mvc.problemdetails.enabled: true`),
+so validation and every other error ride the same shape.
+
+### 5. The whole path, end to end
+
+```text
+stale PUT /api/posts/{id}
+ -> PostService.updatePost re-reads the row inside its transaction
+ -> post.getVersion() != request.version()
+ -> PostVersionConflictException: "fetch it again before retrying"
+ -> ApiExceptionHandler.handleConflict
+ -> HTTP 409, ProblemDetail with code POST_VERSION_CONFLICT
+ -> the client knows its premise was stale
+```
+
+One `@Version` field, one `version` in the request contract, one
+exception, one handler method. Nothing on the controller changed —
+`updatePost` still looks like an ordinary `PUT`.
+
+## Why a conflict is the correct answer, not a retry
+
+When two editors produce two different versions of the same record, the
+server cannot know which intent is the one meant. Both saves are
+legitimate from their authors' point of view, and replaying the stale
+write just replays the same data loss.
+
+The honest verdict is HTTP's own definition of 409: the request conflicts
+with the current state of the resource, and the client is told exactly
+how to resolve it — "fetch it again before retrying". The losing client
+re-reads the record, sees the other writer's change, and a human decides
+what to keep. The silent overwrite becomes a visible decision point,
+which is the entire purpose of the exercise.
+
+## What the rest of the demo proves
+
+The version field is one layer of a small system, and the rest of it is
+verifiable the same way — every row below is a class or a config file in
+the repository, not a claim:
+
+| Layer | What it does | Where it lives |
+|---|---|---|
+| Caching | Bounded Caffeine cache on single-post reads (max 500, TTL 10 min, both from config); `@Cacheable` on read, `@CachePut` on update, `@CacheEvict` on delete; `GET /api/showcase/cache` exposes requests, hits, misses and hit rate | `CacheConfig`, `CacheProperties`, `ShowcaseMetricsController` |
+| Security | Stateless HTTP Basic with BCrypt; pages and read APIs are public, every mutation needs the `EDITOR` role; local defaults are a throwaway `demo-editor`/`changeit` account overridable via env vars | `SecurityConfig`, `application.yml` |
+| Errors | Request records validate at the boundary; one `@RestControllerAdvice` returns consistent problem documents with machine codes — `POST_NOT_FOUND` (404), `POST_VERSION_CONFLICT` (409), `VALIDATION_FAILED` (400) with a field-error map | `CreatePostRequest`, `UpdatePostRequest`, `ApiExceptionHandler` |
+| Configuration | Local profile runs an in-memory H2 database in PostgreSQL mode with `ddl-auto: update` and seeded sample posts; `prod` selects PostgreSQL, takes every credential from `APP_*` env vars and sets `ddl-auto: validate` | `application.yml`, `application-prod.yml` |
+| Tests | One `@SpringBootTest` suite asserts the rendered Thymeleaf pages, unauthenticated 401s versus editor 201s, the `VALIDATION_FAILED` problem response, public search, and cache hits after repeated reads | `DemoApplicationIntegrationTest` |
+
+## What I would do differently for anything real
+
+Two things change the moment this stops being a local demo. The project
+README says both, and they are worth repeating as my own judgement.
+
+First, schema management. I would add Flyway or Liquibase migrations
+before ever setting `ddl-auto` to `validate`. The demo's default profile
+uses `update`, which is convenient on a scratch database and dangerous as
+a habit — production schema should be versioned code, not a side effect
+of startup.
+
+Second, secrets. The local editor account is intentionally a non-secret
+demo account with default credentials. For anything real, I would keep
+credentials in a managed secret store and refuse to boot without them,
+instead of falling back to a default. The `prod` profile already takes
+every credential from environment variables with no committed secrets —
+that is the shape I would keep, minus the fallbacks.
+
+## The result
+
+A stale update now returns HTTP 409 with code `POST_VERSION_CONFLICT`
+instead of a 200 that silently discards the other writer's change. The
+loss can no longer happen quietly: the failure is loud, actionable —
+"fetch it again before retrying" — and the integration test suite asserts
+the access and validation behaviour end to end.
+
+The whole fix is one annotation on one field, plus plumbing that makes
+the version part of the contract. If a record can be read by two people
+at the same time, that field is the difference between a lost edit and a
+conflict both clients can see.
+
+I build Java/Spring back ends and full-stack web applications — REST APIs
+with JPA, optimistic locking, caching, validation and integration tests,
+from the browser page down to the database. If you have a form or a field
+where edits keep quietly disappearing, tell me about it:
+[WhatsApp](https://wa.me/60127972969) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Spring%20Boot%20help) ·
+[hoelee.com](https://hoelee.com).
\ No newline at end of file
diff --git a/src/content/posts/verifying-a-pdf-report-page-by-page.md b/src/content/posts/verifying-a-pdf-report-page-by-page.md
new file mode 100644
index 0000000..0d3c536
--- /dev/null
+++ b/src/content/posts/verifying-a-pdf-report-page-by-page.md
@@ -0,0 +1,240 @@
+---
+title: "Verifying a 19-Page PDF Report Page by Page"
+description: "Verify a PDF report page by page: print the same HTML from your browser as a baseline, compare the per-page geometry, and keep every deviation under 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
+---
+
+How do you know a generated PDF actually looks right on every page — not just
+page one? I maintain a CodeIgniter 4 app that turns birth details into a
+Chinese numerology report. Until recently the operator printed it with Ctrl+P
+and saved a PDF by hand. I replaced it with a server-side renderer (mPDF), and
+overnight the product's quality depended on a layout engine I could not see.
+
+This post is the searchable version of that problem: verifying a multi-page
+PDF report page by page against the browser's own print output, with numbers
+instead of eyeballs. It caught real bugs — a header line rendered at 2.5pt,
+effectively invisible on every page after the first — and ended with all
+nineteen pages within 2pt of the browser.
+
+## Why "looks fine" is not a test
+
+Nobody can verify a 19-page report by eye. The old ritual was: open the PDF,
+skim the cover, ship it. A drifting heading on page 17, a footer illustration
+over the copyright line, an unreadable header — a quick flick never catches
+those, and the customer who paid sees them at full size.
+
+Naive approaches fail structurally. A server-side PDF renderer does not lay
+out HTML the way a browser does, so page breaks, margins, baselines and
+spacing all drift — and there is no "looks fine" test you can re-run after
+someone edits the CSS. My first single-pass render came out as 27 pages where
+the design expects 19 — mPDF has no CSS clipping and the sheets hide overflow
+with `overflow: hidden`. The report's own `@page` rule was worse: mPDF emitted
+a page break for the rule itself, and 19 sheets exploded into 12,789 pages.
+Page counts were not even stable, so eyeballing page one was not verification;
+it was hope.
+
+## The baseline: make the browser print your HTML
+
+The operator's Ctrl+P is Chrome printing the report's own HTML with its print
+CSS — same machine, same web fonts. So the reference is not "what I think it
+should look like"; it is the browser's own print output. I served the report
+directory locally (fonts must be same-origin or webfonts refuse to load) and
+printed with headless Chrome. The report CSS declares `@page { margin: 0 }`,
+so the output is borderless full-bleed, and `-webkit-print-color-adjust:
+exact` keeps the background graphics the operator ticks:
+
+```bash
+python -m http.server 8123 --bind 127.0.0.1 --directory
+
+"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"
+```
+
+Then the server-side output, from the same HTML, on the VM:
+
+```bash
+php tools/pdf-render.php /tmp/report-v10.html /tmp/mpdf.pdf
+```
+
+## Compare page by page, not overall
+
+I compared the two PDFs page by page with `uv run --with pymupdf`: page size,
+text-block count, image bounding boxes (x0/y0/width/height), and caption text
+y. Threshold: **within 2pt (0.7mm) counts as aligned**; over 5pt gets a root
+cause. Compare the same element across the two PDFs — never absolute page
+counts; page parity is the prerequisite, not the test.
+
+## What the comparison caught
+
+Each bug below had a measurable before/after and a fix in the library or
+template.
+
+**The invisible header line.** A user reported the small header line, present
+on every page after the cover, was unreadable. It is a `font-size: 10px` div
+wrapping an auto-width table whose first cell declares `width: 100%` — mPDF
+read that as "table too wide" and scaled the whole line, font included, to a
+third of its size: **2.5pt vs Chrome's 7.5pt**. `fixPageHeaderTables()` gives
+the table an explicit width and font size in points (px → pt) and drops the
+offending cell:
+
+```php
+$pt = round((float) $m[2] * 0.75, 2); // 10px = 7.5pt
+```
+
+**The margin rule that matched too much.** `.sheet { margin: 5mm auto }`
+spaces the sheets on screen. mPDF took it literally and pushed the 296mm sheet
+to 301mm — past the 297mm page — so the sheet was cut at the page edge and
+auto-fit shrank the whole page by 3%, white border included. Appending `.sheet
+{ margin: 0; }` does nothing — mPDF honours the *first* rule for a duplicated
+selector. So the library rewrites the rule in place (`stripSheetMargins()`),
+and the matcher must not over-match — the guard is a negative lookbehind:
+
+```php
+'~(?` tag; mPDF 8's `Center` handler is an empty class, so the tag is
+dropped and every centred heading and table came out left-aligned (the preface
+heading measured x=30 against Chrome's x=280). `expandCenterTags()` rewrites
+`` to `` and adds `align="center"` to
+tables inside centred blocks, because parent `text-align` does not reach
+tables. After the fix: x=281, Chrome 280.
+
+**Line spacing 15% taller than the browser's.** The template's normalize.css
+declares `html { line-height: 1.15 }`; mPDF does not inherit it and fell back
+to its own font metrics (1.33) — 20–40pt of drift accumulated on the lower
+half of every page, and sheet 3 overflowed onto a second page, triggering a
+whole-page shrink. Setting `useFixedNormalLineHeight` to the template's own
+value brought body leading to 15.5pt against Chrome's 15.7. Tables needed `td,
+th { padding: 1px }` — mPDF's default cell padding is 2px larger than a
+browser's.
+
+**The fonts were wrong.** mPDF cannot read the `.woff` files the web views
+use, so text fell back to its built-in Sun-ExtA, and "Microsoft YaHei" does
+not exist on the Linux server. I registered the real TTFs — MaShanZheng for
+headings, Roboto for Latin, wqy-microhei for CJK — and mapped the template's
+stacks onto them. One trap: mPDF's automatic script-to-font selection must be
+off, or it picks the first registered font that supports Chinese — the entire
+body came out in handwriting.
+
+**And one bug that had nothing to do with mPDF.** Page 7's main illustration
+was broken in *both* PDFs — the same broken stub in Chrome and mPDF. The
+template hardcoded `https://cdn.hoelee.com/...`, a domain that no longer
+resolves (NXDOMAIN), so every engine fetched nothing. Fixing the template to
+use the app's own base URL and mapping any host's `/static/` path to local
+files restored it in both engines. Only a side-by-side comparison surfaces
+this — each engine alone looks "fine".
+
+## One sheet, one page
+
+The library splits the HTML into its `
` blocks and
+renders each sheet as its own one-page document, importing page 1 and merging
+— a physical guarantee that one sheet is exactly one A4 page, never split
+across pages. mPDF measures CJK text widths slightly differently from Chrome,
+so some sheets come out a few millimetres too tall. Rather than lose content,
+the library re-renders the sheet at the smallest scale factor from a ladder —
+1.0, 1.005, 1.01, 1.02, 1.03, 1.06, 1.10, 1.15, 1.22 — that fits, shrinking
+the whole sheet instead of clipping. The browser's print clips with `overflow:
+hidden`; a PDF that silently dropped in-sheet content would be a delivery
+accident, so the design never drops content. Sheet 3 needs x1.005 today (0.5%,
+invisible) where it used to need x1.03.
+
+**What "19/7 pages" means.** The report template always renders nineteen
+sheets in a fixed order; the "edition" is a filter over those sheets, not a
+second template. The full report is **19 pages**; the RM49 essence edition is
+**7 of those 19 sheets**, renumbered, each selected sheet carrying a marker so
+a rearranged template fails loudly instead of shipping the wrong chapters.
+Both editions run the same pipeline and verify the same way:
+`tools/pdf-verify.php --expect=19` and `--expect=7` both PASS — page count
+plus a per-page ink check (ghostscript at 50dpi) proving no blank pages.
+
+## Why the invoice is a separate document
+
+The receipt is not a cut-down report. It is its own one-page A4 document: real
+16mm page margins (the report is deliberately full-bleed), three languages,
+and a single-pass render because there are no sheets. It is generated lazily
+at email time — the payment callback must answer in milliseconds, and a 0.3–1s
+render does not belong in it. It lands in the same delivery store under a
+`-receipt` filename, never colliding with the report file, and it is
+idempotent: retries get the same file. Even one page differed from the
+browser: `display: block` on `` was ignored, side-by-side tables
+clipped the right column's values off the page edge, and the total row had to
+live inside the items table or its label floated in mid-air.
+
+## What I'd do differently
+
+The method lives in the project notes as a documented ritual, not a committed
+script — that is the gap. The repo's automated acceptance tool proves page
+count and per-page ink, which would never catch a 2.5pt header or a 15%
+leading drift. I would turn the browser-baseline comparison into a script in
+the repo's verification tooling: render the same HTML in Chrome and in the
+library, diff the geometry, fail on any deviation over 5pt. Then a CSS change
+that silently regresses the print layout fails the build instead of reaching a
+customer.
+
+I would also have generated the Chrome baseline before writing any mPDF
+compensation code — "print with the same engine the operator uses, then
+measure" was the unlock. Two honest items remain: the partner and family
+reports have not had this page-by-page pass yet, and the 19-page PDF (20 MB
+with backgrounds and embedded fonts) still needs compression before delivery.
+
+## The result
+
+Full report verification, after the fixes:
+
+```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 pages vs the Chrome baseline's 19 — MATCH**, one A4 page per sheet,
+ no splits.
+- Every measured deviation is **within 2pt (0.7mm)** of the browser's print
+ output: footer art 213.5pt off on page 6 aligned, header restored to the
+ browser's 7.5pt, centred headings back, leading 15.5pt vs 15.7, embedded
+ fonts matching the web fonts.
+- The ink check confirms **no blank pages**, and the PDF text is extractable —
+ the cover reads back as real text, which matters for a report customers
+ copy from.
+- Essence edition: **7 pages PASS**, 4.99 MB, 2.8 seconds.
+
+That is the difference between "the first page looks fine" and "all nineteen
+pages within two points of the browser": the first ships when you eyeball a
+PDF; the second is what you get when the browser itself is the test.
+
+---
+
+I build web applications and print/PDF report pipelines like this one, and I
+do website design and development. If you have a document your server renders
+— a report, a receipt, an invoice — and you want to be sure it is right on
+every page before it reaches a customer, tell me about it:
+[WhatsApp](https://wa.me/60127972969) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=PDF%20report%20pipeline) ·
+[hoelee.com](https://hoelee.com).
+
diff --git a/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md b/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md
new file mode 100644
index 0000000..b9b8f41
--- /dev/null
+++ b/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md
@@ -0,0 +1,176 @@
+---
+title: "Why My On-Chain NFT Art Changed When I Cloned It on Windows"
+description: "Why my on-chain NFT art changed on Windows: core.autocrlf injects CRLF into the SVG files vm.readFile encodes, the base64 differs, and eol=lf fixes it."
+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
+---
+
+A fully on-chain NFT's artwork is supposed to be permanent. The image is
+not a URL someone can unpin — it is a base64 string stored in the
+contract's storage at deploy time, and after that it cannot change. So
+the question writes itself: how can artwork like that change at all, let
+alone silently?
+
+In my case the answer was: the art was never a fixed string. It was
+whatever bytes my deploy script happened to read from disk on the machine
+that ran the deploy — and on Windows, Git quietly rewrites those bytes
+before the script ever sees them.
+
+I hit this in a small Foundry learning project built around a "mood"
+NFT: an ERC-721 whose artwork flips between a smiling SVG and a sad SVG,
+both encoded in the contract itself. The image URIs are built at deploy
+time from source files in `img/`, so the byte-for-byte content of those
+files is the artwork. Here is how one bad line ending nearly made that
+art machine-dependent.
+
+## What I hit: the deploy encodes whatever bytes it reads
+
+The deploy script does the whole job in a few lines:
+
+```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` returns a string, `Base64.encode` turns those exact bytes
+into a `data:image/svg+xml;base64,...` URI, and the constructor stores
+both URIs forever. "On-chain" here is literal: whatever bytes the file
+had on the deploying machine are now the contract's data — permanently.
+A one-byte difference in the SVG at deploy time is a different artwork,
+baked in for the life of the contract.
+
+The difference I was worried about comes from Git's `core.autocrlf`.
+A Windows Git install commonly rewrites text files with CRLF line
+endings in the working tree, even when the repository stores LF. SVG
+files are text. CRLF and LF are different bytes, and base64 encodes
+different bytes differently. Two lines prove it:
+
+```bash
+printf 'a\nb' | base64 # YQpi
+printf 'a\r\nb' | base64 # YQ0KYg==
+```
+
+One carriage return changes the encoded payload. Now the nasty part of
+this failure class: nothing announces it. The SVG looks identical in
+every editor. `git status` stays clean, because Git compares text after
+normalising line endings. Foundry does not care either — it is not
+parsing the SVG, just encoding bytes — so no error, no warning, on any
+machine. The art baked into the contract silently depends on which
+machine ran the deploy.
+
+## The fix: one rule in .gitattributes
+
+The fix is a single file with a single rule, and the comment matters:
+
+```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
+```
+
+Why this works: `text` tells Git to treat those files as text and
+normalise them, so in the repository they are always stored with LF.
+`eol=lf` then pins the working-tree checkout of those paths to LF,
+overriding whatever `core.autocrlf` says on any machine. The two
+attributes together mean that on a Windows box with
+`core.autocrlf=true`, `img/*.svg` are still checked out with LF — so
+`vm.readFile` always returns the same bytes the author committed, and
+the base64 URI is deterministic across platforms.
+
+One precision: the rule governs checkout of those paths, and
+normalisation when files are added. It did not rewrite the source SVGs
+— they were already committed with LF, and the rule does not touch blob
+content. What it prevents is the divergence on every checkout after it
+lands.
+
+The same commit fixed a second thing that was silently wrong: a typo in
+the `remappings` entry in `foundry.toml`. The mapping is what makes
+`@openzeppelin/contracts/...` imports resolve to the submodule, so a
+typo there breaks the build with an error that has nothing to do with
+the code I wrote:
+
+```toml
+remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]
+```
+
+## Two traps sitting right next to this one
+
+### Trap 1: vm.readFile refuses to run without fs_permissions
+
+`vm.readFile` is an *fs cheatcode* — Foundry will not let a script touch
+the filesystem unless the path is explicitly granted in `foundry.toml`:
+
+```toml
+fs_permissions = [
+ { access = "read", path = "./img/" },
+ { access = "read", path = "./broadcast" },
+]
+```
+
+Read access to `./img/` is for the SVGs; `./broadcast` is there so the
+mint script's DevOpsTools helper can find the latest deployment log.
+Without the grant the deploy fails at the first read — another
+near-silent failure, because the error points at the cheatcode, not at
+your code.
+
+### Trap 2: DevOpsTools needs ffi = true
+
+The interaction scripts import DevOpsTools from `foundry-devops` to
+locate the last deployment instead of hardcoding an address. That import
+needs Foundry's `ffi` cheatcode enabled, so the config carries it with
+an explanatory comment:
+
+```toml
+ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol
+```
+
+`ffi` is a real privilege grant — it lets scripts run arbitrary shell
+commands — so it deserves that comment, and it is worth knowing exactly
+which import requires it before you enable it.
+
+## What I'd do differently
+
+Two habits would have caught this earlier and would catch the next
+line-ending regression:
+
+1. **Assert on the encoded bytes in a test.** The integration tests
+ already run the real deploy script, so the harness exists. A unit
+ test that asserts `vm.readFile("img/smile.svg")` — or the final image
+ URI — equals the expected LF-encoded base64 string would make a CRLF
+ regression fail `forge test` loudly, instead of quietly shipping
+ different art to a chain.
+2. **Pin `eol=lf` for every asset directory a script reads as bytes.**
+ The trap is not specific to SVG. If a script embeds JSON metadata or
+ any other text asset, the same thing happens. As a rule of thumb:
+ anything read with an fs cheatcode gets a `.gitattributes` rule
+ before the script is committed.
+
+## The result
+
+After the fix, the base64 payload is identical on a Windows checkout and
+a Linux checkout — one string, computed on two machines, byte for byte
+equal — so the deployed contract's art is reproducible rather than
+machine-dependent. That is the whole point of on-chain art, and it
+turned out to be one file, one rule, and one `printf` away from being
+quietly broken.
+
+Honest scope: this is a learning project — a small, heavily commented
+contract and deploy script run against a local Anvil node and the
+Sepolia testnet, not production code. But "works on my machine" is a
+bug report, and the fix here is the same discipline a production
+deployment needs: know exactly what bytes your tooling is shipping.
+
+I do full-stack web development for a living — front-end, back-end,
+self-hosted deployment — and this class of byte-level, cross-platform
+debugging is exactly what shipping software involves. If your project
+needs a developer who checks the bytes, not just the diff, tell me about
+it: [WhatsApp](https://wa.me/60127972969) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Full-stack%20web%20development)
+· [hoelee.com](https://hoelee.com).
\ No newline at end of file
diff --git a/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md b/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md
new file mode 100644
index 0000000..579e7cf
--- /dev/null
+++ b/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md
@@ -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)
+· [me@hoelee.com](mailto:me@hoelee.com?subject=Full-stack%20web%20development)
+· [hoelee.com](https://hoelee.com)。
\ No newline at end of file
diff --git a/src/content/posts/zh/fully-on-chain-svg-nfts.md b/src/content/posts/zh/fully-on-chain-svg-nfts.md
new file mode 100644
index 0000000..dae8695
--- /dev/null
+++ b/src/content/posts/zh/fully-on-chain-svg-nfts.md
@@ -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://` 交给合约,让钱包和市场通过网关去解析。渲染能成功,依赖
+两件事一直成立:**有人持续 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) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Web%20development%20project) ·
+[hoelee.com](https://hoelee.com)。
\ No newline at end of file
diff --git a/src/content/posts/zh/jpa-version-field-lost-update.md b/src/content/posts/zh/jpa-version-field-lost-update.md
new file mode 100644
index 0000000..0610c85
--- /dev/null
+++ b/src/content/posts/zh/jpa-version-field-lost-update.md
@@ -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) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Spring%20Boot%20help) ·
+[hoelee.com](https://hoelee.com)。
\ No newline at end of file
diff --git a/src/content/posts/zh/verifying-a-pdf-report-page-by-page.md b/src/content/posts/zh/verifying-a-pdf-report-page-by-page.md
new file mode 100644
index 0000000..2793b45
--- /dev/null
+++ b/src/content/posts/zh/verifying-a-pdf-report-page-by-page.md
@@ -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
+
+"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
+'~(?` 标签
+居中,而 mPDF 8 的 `Center` 标签处理器是空类——标签整个被
+丢弃,所有居中的标题和表格全部左对齐(「前言」实测 x=30,
+Chrome 是 x=280)。`expandCenterTags()` 把 `` 改写成
+``,并给居中块里的表格补上
+`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 按 ``
+切开,每张 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` 后缀,永远不会和报告
+文件撞名,而且幂等:重试、重寄、顾客自己来拿,拿到的都是
+同一份。做它的过程从另一个方向印证了同一个论点——连单页
+文档都和浏览器不一样:`` 上的 `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) · [me@hoelee.com](mailto:me@hoelee.com?subject=PDF%20report%20pipeline) ·
+[hoelee.com](https://hoelee.com)。
\ No newline at end of file
diff --git a/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md b/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md
new file mode 100644
index 0000000..f90adc2
--- /dev/null
+++ b/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md
@@ -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) ·
+[me@hoelee.com](mailto:me@hoelee.com?subject=Full-stack%20web%20development)
+· [hoelee.com](https://hoelee.com)。
\ No newline at end of file