Showing Posts From

技术

EIP-1559 交易脚本的六个 Gas 坑:baseFee 突变到 nonce 冲突

写一个"清仓转账"的脚本——把地址里所有余额扫到目标地址,用 EIP-1559。核心代码大致这样: base_fee = w3.eth.get_block("latest")["baseFeePerGas"] priority_fee = w3.to_wei(0.1, "gwei") max_fee_per_gas = base_fee + priority_feegas_limit = 30000 fee = gas_limit * max_fee_per_gas amount = balance - feetx = { "to": w3.to_checksum_address(dst), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": w3.eth.get_transaction_count(from_address), "chainId": CHAIN_ID, "type": 2, }跑起来经常报: insufficient funds for gas * price + value问题不是一处,是好几个细节叠加。 坑 1:maxFeePerGas 写死等于 baseFee + priority 节点校验时: balance >= value + gasLimit × maxFeePerGasbaseFee 每个区块都会浮动(EIP-1559 每次可变 ±12.5%)。你算完刚好够,几秒后新块 baseFee 涨了: max_fee < new_base_fee + priority要么交易过不了、要么"钱不够"。MetaMask 的做法是: max_fee_per_gas = base_fee * 2 + priority_feebase_fee * 2 是常规冗余系数——baseFee 一个区块最多涨 12.5%,2x 完全够缓冲。多花的部分节点会退还给你,只按 effectiveGasPrice 收。 坑 2:gasLimit 硬编码 普通转账 21000 够,Gravity 这类链要 30000。合约调用差别就更大了。别写死,先估算再加冗余: gas_limit = w3.eth.estimate_gas({ "from": from_address, "to": dst, "value": 1, }) gas_limit = int(gas_limit * 1.2)* 1.2 是通用的 20% 余量,避免 estimation 边缘 case。 坑 3:priority fee 硬编码 0.1 gwei 网络拥堵时 0.1 gwei 的小费根本进不了下一个块。用节点建议值: priority_fee = w3.eth.max_priority_fee或者查 feeHistory 取近期百分位: history = w3.eth.fee_history(5, "latest", [50]) priority_fee = max(history["reward"][-1][0], w3.to_wei(1, "gwei"))坑 4:nonce 用了 latest 默认: w3.eth.get_transaction_count(from_address) # 等价于 "latest"如果地址有未确认的 pending 交易,latest 会返回过时的 nonce,新交易和旧的撞车,两个都卡住。改成: nonce = w3.eth.get_transaction_count(from_address, "pending")坑 5:清仓转账没留冗余 amount = balance - fee 是理论极限。实际因为 baseFee 突变或 estimation 偏差,几乎必然凑不够。留一点小额缓冲: reserve = w3.to_wei(0.0001, "ether") amount = balance - fee - reserve坑 6:chainId 抄错 "chainId": 127001,链 ID 不匹配的交易节点直接拒。跟节点确认一次: print(w3.eth.chain_id)修正后的样板 base_fee = w3.eth.get_block("latest")["baseFeePerGas"] priority_fee = w3.eth.max_priority_fee max_fee_per_gas = base_fee * 2 + priority_feegas_limit = w3.eth.estimate_gas({ "from": from_address, "to": dst, "value": 1, }) gas_limit = int(gas_limit * 1.2)fee_ceiling = gas_limit * max_fee_per_gas reserve = w3.to_wei(0.0001, "ether") amount = balance - fee_ceiling - reserve if amount <= 0: raise RuntimeError("余额不足以支付 Gas + 冗余")tx = { "to": w3.to_checksum_address(dst), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": w3.eth.get_transaction_count(from_address, "pending"), "chainId": w3.eth.chain_id, "type": 2, }一句话总结 EIP-1559 参数不是"算准就行",是"抗突变才稳"。maxFee = baseFee * 2 + priority、gas = estimate * 1.2、nonce = pending,三件事一起做,出问题的概率会低一个数量级。

BIP39 助记词生成 Solana 地址:SLIP-10 + Ed25519 全流程

BIP39 助记词生成 ETH 地址靠 secp256k1 + keccak256,走 Solana 就完全换了一套——Ed25519 + SLIP-0010,最后不做 hash,直接 Base58 编码公钥。 全流程 助记词 (Mnemonic) │ PBKDF2-HMAC-SHA512 ▼ Seed (64 字节) │ SLIP-0010 (Ed25519) ▼ Master Key │ 按路径 m/44'/501'/0'/0' 派生 ▼ Ed25519 私钥 (32 字节) │ Ed25519 公钥算法 ▼ 公钥 (32 字节) │ Base58 编码 ▼ Solana 地址和 BTC/ETH 唯一相同的只有第一步"助记词转 seed",之后全变了。 第一步:助记词 → seed 标准 BIP39,PBKDF2-HMAC-SHA512: password = 助记词字符串 salt = "mnemonic" + passphrase iters = 2048 output = 64 字节Python: from mnemonic import Mnemonicmnemo = Mnemonic("english") seed = mnemo.to_seed( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about", passphrase="", ) print(seed.hex())第二步:seed → Master Key(SLIP-10 Ed25519) Solana 用 SLIP-0010(Ed25519 版本),和 BIP32 的 secp256k1 分道扬镳: I = HMAC-SHA512(key="ed25519 seed", data=seed) IL = master private key (32 字节) IR = master chain code (32 字节)第三步:派生路径 Solana 默认路径: m/44'/501'/0'/0'44':BIP44 501':Solana 的 coin type 0':account 0':change每一级都是 hardened derivation(' 表示 + 2³¹)。Ed25519 只支持 hardened 派生,非 hardened 会直接报错。 第四步:公钥 & 地址 私钥经 Ed25519 算出 32 字节公钥。地址 = Base58(公钥),就这么简单:没有 SHA-256 double hash 没有 RIPEMD-160 没有 keccak 没有 checksum(Base58 本身没自带校验,也没有像 BTC 那样附 4 字节 hash)Python 一把梭 bip_utils 把上面所有细节封好了: from bip_utils import Bip39SeedGenerator, Bip44, Bip44Coinsmnemonic = ("abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about")seed = Bip39SeedGenerator(mnemonic).Generate()bip44 = Bip44.FromSeed(seed, Bip44Coins.SOLANA) account = bip44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(0)print("地址:", account.PublicKey().ToAddress()) print("私钥:", account.PrivateKey().Raw().ToHex())Phantom / Solflare 里的第一个账号导出出来应该跟上面一样。 常见误区 Solana 地址和 ETH 地址长得像 其实完全不同:ETH 地址:40 字符 hex + 0x 前缀,checksum 在 EIP-55 里靠大小写实现 Solana 地址:Base58 编码的 32 字节公钥,长度 32~44 字符,无固定前缀Solana 私钥文件是 64 字节而不是 32 Solana 的钱包 JSON(Phantom 导出)通常是 64 字节:前 32 是私钥 seed、后 32 是公钥。生成时: priv32 = account.PrivateKey().Raw().ToBytes() pub32 = account.PublicKey().RawUncompressed().ToBytes()[-32:] keypair_bytes = priv32 + pub32 # 64 字节导入 solana-cli 或 Phantom 时用这个格式。 Ed25519 派生路径可以省略部分层级 Phantom 早期用 m/44'/501'/0'(只三层)而非 m/44'/501'/0'/0'——同一助记词在这两种路径下算出来的地址不同。发现"钱包地址对不上"时先检查是不是路径差异,试试:m/44'/501'/0' — Phantom "legacy" m/44'/501'/0'/0' — 现在的默认一句话总结 Solana 地址生成 = BIP39 seed → SLIP-10 Ed25519 派生 → Base58(公钥)。和 EVM 完全两套加密路径,别拿 ETH 的经验类比。用 bip_utils 三行搞定,别自己实现 Ed25519。

Python 读取 YAML:safe_load 和转对象访问

Python 里读 YAML 配置文件,标准做法就是 PyYAML。 安装 pip install pyyaml示例配置 config.yaml: server: host: 127.0.0.1 port: 8000database: user: root password: 123456names: - alice - bob基础读取 import yamlwith open("config.yaml", "r", encoding="utf-8") as f: data = yaml.safe_load(f)print(data["server"]["host"]) # 127.0.0.1 print(data["names"]) # ['alice', 'bob']永远用 safe_load,别用 yaml.load。safe_load 拒绝任意 Python 对象反序列化,避开加载不受信配置时的 RCE 风险。 转对象访问(少写引号) 字典下标写多了嫌烦,可以套一层 SimpleNamespace: from types import SimpleNamespace import yamlwith open("config.yaml", encoding="utf-8") as f: data = yaml.safe_load(f)cfg = SimpleNamespace(**data) print(cfg.server) # 但 cfg.server 还是 dict只包一层不够——嵌套的字典还得递归转: from types import SimpleNamespace import yamldef to_obj(d): if isinstance(d, dict): return SimpleNamespace(**{k: to_obj(v) for k, v in d.items()}) if isinstance(d, list): return [to_obj(i) for i in d] return dwith open("config.yaml", encoding="utf-8") as f: cfg = to_obj(yaml.safe_load(f))print(cfg.server.host) # 127.0.0.1 print(cfg.database.user) # root工程项目更推荐用 pydantic.BaseModel 或 dataclass——能顺带做类型校验: from pydantic import BaseModel import yamlclass ServerCfg(BaseModel): host: str port: intclass Config(BaseModel): server: ServerCfg names: list[str]with open("config.yaml", encoding="utf-8") as f: cfg = Config(**yaml.safe_load(f))print(cfg.server.host)输出 YAML 反向写文件同样常用: import yamldata = {"server": {"host": "0.0.0.0", "port": 80}}with open("out.yaml", "w", encoding="utf-8") as f: yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)allow_unicode=True 中文不会变成 \uXXXX sort_keys=False 保持字段顺序一句话总结 yaml.safe_load(open(..., encoding="utf-8")) 起步,需要结构化访问就 pydantic。写回用 safe_dump + allow_unicode=True。

ModuleNotFoundError: 'Crypto' — pycryptodome 与 pycryptodomex 的坑

代码里: from Crypto.Cipher import AES一跑就崩: ModuleNotFoundError: No module named 'Crypto'网上一查装个 pycrypto——别装,那个包 2013 年就停维护了,还有已知漏洞。 两个包,两个命名空间 现在的正主是这两个,装错任何一个都不通用:包名 导入命名空间pycryptodome from Crypto...pycryptodomex from Cryptodome...pycryptodome:兼容老代码,占用 Crypto 顶层名字 pycryptodomex:改名版本,把命名空间挪到 Cryptodome,避免和老 pycrypto 冲突同一个环境里最多装一个。要是老代码用 from Crypto...,就装 pycryptodome。 装到虚拟环境里,别装到系统 Python PyCharm / VSCode 打开项目通常自动激活了虚拟环境(比如 .venv),但你在别的终端 pip install 时可能装到系统 Python 去了: pip install pycryptodome # 装哪儿了?看运气最稳的写法是显式用当前项目的 Python: D:\Projects\myapp\.venv\Scripts\python.exe -m pip install pycryptodome或者: py -m pip install pycryptodome # Windows py launcher python -m pip install pycryptodome # 当前激活的解释器检查装到哪里了 where python python -c "import sys; print(sys.executable)" python -m pip show pycryptodomeLocation 字段就是包所在目录,肉眼比对一下是不是当前项目的 site-packages。 老代码遇到 pycryptodomex 某些镜像里预装的是 pycryptodomex。老代码不想改的话,兼容层写一下: try: from Crypto.Cipher import AES except ImportError: from Cryptodome.Cipher import AES一句话总结 Crypto = pycryptodome,Cryptodome = pycryptodomex,二选一装到当前项目 Python 里。用 python -m pip install 比裸 pip install 稳。

Python list 转 dict/map 的五种常见姿势

Python 里说"list 转 map"通常指的是把列表变成字典(dict)。到底怎么转,取决于原始 list 长什么样。 1. 两个平行 list 转 dict keys 和 values 是分开的两列: keys = ["a", "b", "c"] values = [1, 2, 3]m = dict(zip(keys, values)) # {'a': 1, 'b': 2, 'c': 3}2. 二元组 list 转 dict 已经是 (key, value) 对: data = [("a", 1), ("b", 2), ("c", 3)] m = dict(data) # {'a': 1, 'b': 2, 'c': 3}3. 字典 list 按字段索引(最常用) 后端返回一堆对象,前端要按 ID 查——最典型场景: users = [ {"id": 1, "name": "Tom"}, {"id": 2, "name": "Jack"}, {"id": 3, "name": "Lucy"}, ]m = {u["id"]: u for u in users}m[2] # {'id': 2, 'name': 'Jack'}如果 key 会重复,改成分组: from collections import defaultdictgroups = defaultdict(list) for u in users: groups[u["dept"]].append(u)4. Python 内建 map() 函数 如果说的是 map() 而不是"映射": nums = [1, 2, 3, 4] result = list(map(lambda x: x * 2, nums)) # [2, 4, 6, 8]更 Pythonic 的写法是列表推导: result = [x * 2 for x in nums]5. 统计次数 用 Counter: from collections import Counterlst = ["a", "b", "a", "c", "a"] Counter(lst) # Counter({'a': 3, 'b': 1, 'c': 1})记忆表输入形态 用法两列 keys + values dict(zip(keys, values))[(k, v), ...] dict(data)[{...}, ...] 按字段 {u[key]: u for u in list}对每个元素求值 [f(x) for x in list]统计次数 Counter(list)

Nginx 反向代理改造成负载均衡:踩坑与正确写法

原本站点是这样的反向代理: location / { proxy_pass http://192.168.5.31:8000; proxy_set_header Host $host:$server_port; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }后端加机器了,要改成负载均衡。看着简单,但一路踩了两个坑。 步骤一:定义 upstream 轮询模式最简单: upstream backend { server 192.168.5.31:8000; server 192.168.5.32:8000; server 192.168.5.33:8000; }location 里的 proxy_pass 改成: proxy_pass http://backend;三种策略怎么选 WebSocket / 长连接:ip_hash 同一个客户端 IP 固定打到同一台机器,session 不会乱: upstream backend { ip_hash; server 192.168.5.31:8000; server 192.168.5.32:8000; }机器性能不同:weight upstream backend { server 192.168.5.31:8000 weight=4; server 192.168.5.32:8000 weight=2; server 192.168.5.33:8000 weight=1; }流量大致按 57% / 29% / 14% 分。 AI 推理、长请求:least_conn 轮询在这种场景很糟糕——某台机器可能同时挤了 5 个几十秒的推理请求,其他机器闲着。least_conn 会挑当前连接数最少的: upstream backend { least_conn; server 192.168.5.31:8000; server 192.168.5.32:8000; server 192.168.5.33:8000; }顺手加个健康检查,挂了的自动摘除: server 192.168.5.31:8000 max_fails=3 fail_timeout=30s;坑一:upstream directive is not allowed here 改完 reload,直接报: nginx: [emerg] "upstream" directive is not allowed here in D:\phpstudy_pro\Extensions\Nginx1.15.11/conf/vhosts/127.0.0.1_8000.conf:24upstream 只能写在 http {} 块里,不能写进 server {} 或 location {}。而 PHPStudy 类面板的 vhost 文件通常等价于一个 server 配置片段,里面就不能定义 upstream。 正确做法:把 upstream backend {...} 挪进主 nginx.conf 的 http {} 里,vhost 文件里只放 location。 # nginx.conf http { upstream backend { least_conn; server 192.168.5.31:8000; server 192.168.5.32:8000; } include vhosts/*.conf; }坑二:proxy_pass 少了协议头 写成: proxy_pass backend;Nginx 会直接报语法错误。proxy_pass 必须带上 http:// 或 https://: proxy_pass http://backend;检查与重载 nginx -t # 语法检查 nginx -s reload # 平滑重载一句话总结 upstream 放 http {}、proxy_pass 别忘 http://、AI/长任务优先 least_conn。三点一起做好,反代改负载均衡基本一次成。

uv 依赖冲突:requires-python 太宽 + Windows 装 triton 的双坑

上一个 CV 项目,uv sync 直接崩: × No solution found when resolving dependencies for split (markers: python_full_version == '3.8.*'): ↳ Because the requested Python version (>=3.8) does not satisfy Python>=3.9.0 and numpy>=1.26.0 depends on Python>=3.9,<3.13, we can conclude that numpy>=1.26.0 cannot be used.看起来是"numpy 装不上",其实是 Python 版本范围和依赖范围打架。 症状识别:requires-python 太宽 pyproject.toml 里如果写了: requires-python = ">=3.8"uv 会尝试为 3.8 / 3.9 / ... / 3.12 全部找一套解。numpy ≥ 1.26 要求 Python ≥ 3.9,Python 3.8 直接被排除——solver 认为"你说要支持 3.8,但 3.8 没解",结论:无解。 解法:收紧 requires-python 改成现代 AI 项目实际支持的范围: requires-python = ">=3.10,<3.13"理由:numpy ≥ 1.26 要求 Python ≥ 3.9 torch 2.x + Windows 的 wheel 覆盖到 3.12 3.13 太新,很多 native wheel 还没出之后 uv sync 通常就过了。 另一坑:Windows 上 triton 无解 同一个项目继续装: uv pip install triton× No solution found: triton<=2.1.0 has no wheels with a matching Python ABI tag (e.g., cp312) triton>=2.2.0 has no wheels with a matching platform tag (win_amd64)不是版本冲突,是 triton 官方根本不出 Windows wheel。Linux 有 manylinux_x86_64,macOS 部分版本有,Windows 是空白。 Windows 上处理 triton 的三条路 方案 A:上 WSL2(推荐) 装个 Ubuntu,在里面: python3.11 -m venv venv source venv/bin/activate pip install triton torchCUDA、torch、triton 生态齐活。 方案 B:直接放弃 triton 只是跑 inference 或 demo,很多模型不强依赖 triton: uv remove triton代码里如果有 import triton,包一层 try/except 或者按平台条件装: [project.optional-dependencies] gpu = ["triton; sys_platform == 'linux'"]方案 C:走 Linux 服务器 有云 GPU 就直接扔上去,从头就没 Windows 这个问题。 uv 常用的调试思路 # 看清依赖树里谁在拉某个包 uv tree | grep numpy# 只解析不装,快速试冲突 uv lock# 指定用某个 Python uv sync --python 3.11# 显式约束某个包 uv add "numpy>=1.26,<2"一句话总结 uv 报 no solution,先看两点:requires-python 范围是不是包了依赖不支持的旧 Python、目标平台是不是根本没轮子。Windows 上装 triton 是无解,别死磕。

ThinkPHP 5.1 老项目 + Composer 2:think-installer 装不上

维护一个老 ThinkPHP 5.1 项目,composer install 直接崩: Problem 1 - topthink/think-installer is locked to version v2.0.0 and an update of this package was not requested. - topthink/think-installer v2.0.0 requires composer-plugin-api ^1.0 -> found composer-plugin-api[2.9.0] but it does not match the constraint. Problem 2 - topthink/framework v5.1.39 requires topthink/think-installer 2.* ...顺带还有: The "https://packagist.phpcomposer.com/packages.json" file could not be downloaded (404)两个问题:Composer 版本不兼容 + 镜像源死了。 症状 1:think-installer 只支持 Composer 1 topthink/think-installer v2.0.0 明确要 composer-plugin-api ^1.0,也就是 Composer 1.x 的插件接口。你机器上是 Composer 2.x,插件接口版本变成 2.x,直接不兼容。 TP5.1 上线那会儿(2018)Composer 2 还没出,这问题在当时不存在,遗留下来才成了坑。 解法:降回 Composer 1 推荐、最省事、也最不折腾业务代码。 方法 A:Composer 自己降版本 composer self-update --1Composer 会在原地切到最新的 1.x(一般是 1.10.27)。想指定: composer self-update 1.10.27方法 B:装一个独立的 Composer 1 不想动全局 Composer 的话,单独下一份: curl -sS https://getcomposer.org/installer | php -- --1 mv composer.phar /usr/local/bin/composer1 composer1 install老项目用 composer1,新项目继续用 composer。 症状 2:packagist.phpcomposer.com 已凉 老教程里推荐的 packagist.phpcomposer.com 这个镜像已经关服多年,仍然响应 404。检查 composer.json: cat composer.json | grep -A 3 repositories看到: "repositories": { "packagist": { "type": "composer", "url": "https://packagist.phpcomposer.com" } }改成官方源或者阿里镜像: # 官方 composer config -g repo.packagist composer https://repo.packagist.org# 阿里云 composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/# 清华(有时更快) composer config -g repo.packagist composer https://mirrors.tuna.tsinghua.edu.cn/composer/-g 是全局,改单个项目就去掉。 也可以直接改 composer.json 里的 repositories 字段,同样效果。 完整恢复流程 # 1. Composer 降 1 composer self-update --1# 2. 换镜像 composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/# 3. 清缓存 composer clear-cache# 4. 重装依赖 rm -rf vendor composer.lock composer install90% 的老 TP5.1 项目这一套下来就恢复了。 什么时候该升级项目 如果这不是紧急救火,考虑:升到 ThinkPHP 6 或 8,从 5.1 迁移文档官方有 或者只把 topthink/think-installer 换成 topthink/framework 的现代版本,去掉这个已废弃的插件但生产环境急救时先别动这些——先降 Composer 让站起来,稳定了再排期改造。 一句话总结 think-installer 只兼容 Composer 1,最直接的解法是 composer self-update --1。顺手把死了的 phpcomposer.com 镜像换成阿里或官方源。

Python JSONDecodeError 排查清单:从 NaN 到编码 BOM

json.loads 抛 JSONDecodeError 时,异常信息里有行号、列号、字节偏移量——先把这三个印出来,问题基本就定位了: import jsontry: data = json.loads(content) except json.JSONDecodeError as e: print(e) print("line:", e.lineno, "col:", e.colno, "pos:", e.pos) print(content[max(0, e.pos - 40): e.pos + 40])最后一行把出错位置前后 40 个字符打出来,肉眼就能看出问题。以下是几种常见踩坑。 1. 根本不是合法 JSON(最常见) 抓了一段"对象序列"贴进来: { "id": 1 }, { "id": 2 }这不是合法 JSON,缺个数组外壳。要么改成: [ { "id": 1 }, { "id": 2 } ]要么按字段包一层: { "objects": [...] }2. NaN / Infinity 没引号 标准 JSON 不支持 NaN、Infinity。这样合法: {"z": "NaN"} ← 字符串这样非法: {"z": NaN} ← Python json.loads 会崩服务端如果是 Python json.dumps(..., allow_nan=True) 输出的,可能就是裸 NaN。解决办法: import json data = json.loads(content, parse_constant=lambda x: None) # 或 data = content.replace("NaN", "null") data = json.loads(data)3. 结尾多逗号 也是非标准 JSON: { "a": 1, "b": 2, ← 这个逗号 Python 不接受 }用 json5 或者预处理正则去掉最后一个逗号: import re content = re.sub(r",\s*([}\]])", r"\1", content)4. UTF-8 BOM 从 Windows 记事本另存的 JSON 常带 BOM,第一个字符是 : json.loads(content) # JSONDecodeError: Expecting value: line 1 column 1 (char 0)读文件时用 utf-8-sig: with open("data.json", encoding="utf-8-sig") as f: data = json.load(f)5. 数字全被当字符串了 不会抛异常,但会让下游算错: {"x": "1228.86"}type(obj["x"]) 是 str,要计算得先转: x = float(obj["x"])批量转的话用一个小函数: def to_float_fields(d, fields): for f in fields: if f in d: d[f] = float(d[f]) return d6. 单引号不是 JSON 从 Python print(dict) 复制的字符串是单引号: {'id': 1, 'name': 'Tom'}这不是 JSON,是 Python repr。要么请对方 json.dumps 输出,要么本地 ast.literal_eval: import ast data = ast.literal_eval("{'id': 1, 'name': 'Tom'}")literal_eval 只解析字面量,比 eval 安全得多。 一句话总结 JSONDecodeError 第一步永远是把 e.pos 附近的原文打出来。九成情况能在 30 秒内定位到具体字符是啥。

用 Composer 从零搭一个 PHP mini 框架:路由 + PSR-4 + 容器

想理解 Laravel、Symfony 是怎么跑起来的,别一头扎进它们的源码——十几万行随便一个 ServiceProvider 就把人绕晕。自己用 Composer 手写一个 100 行的 mini 框架,把路由、自动加载、IoC 弄明白,再去看大框架就轻松了。 目录结构 my-framework/ ├── app/ │ └── Controller/ │ └── HomeController.php ├── public/ │ └── index.php ├── src/ │ ├── Router.php │ ├── Container.php │ └── Response.php ├── vendor/ # composer install 后生成 └── composer.jsonapp/ 是业务代码、src/ 是框架核心、public/ 是入口。 composer.json 与 PSR-4 mkdir my-framework && cd my-framework composer init改 composer.json: { "name": "you/mini-framework", "type": "project", "require": { "php": ">=8.1" }, "autoload": { "psr-4": { "App\\": "app/", "Framework\\": "src/" } } }生成自动加载器: composer dump-autoloadPSR-4 的规则很简单:类的命名空间路径 = 文件相对路径。App\Controller\HomeController 就在 app/Controller/HomeController.php。 Router:50 行的路由 src/Router.php: <?php namespace Framework;class Router { private array $routes = []; public function get(string $path, callable|array $handler): void { $this->routes['GET'][$path] = $handler; } public function post(string $path, callable|array $handler): void { $this->routes['POST'][$path] = $handler; } public function dispatch(): void { $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $handler = $this->routes[$method][$path] ?? null; if (!$handler) { http_response_code(404); echo "404 Not Found"; return; } // 支持 [Controller::class, 'method'] if (is_array($handler)) { [$class, $method] = $handler; $instance = new $class(); echo $instance->$method(); return; } // 支持匿名函数 echo $handler(); } }入口 public/index.php: <?php require __DIR__ . '/../vendor/autoload.php';use Framework\Router; use App\Controller\HomeController;$router = new Router();$router->get('/', fn() => "Hello Framework"); $router->get('/home', [HomeController::class, 'index']);$router->dispatch();Controller: <?php namespace App\Controller;class HomeController { public function index(): string { return "home page"; } }跑起来: php -S localhost:8000 -t public访问 http://localhost:8000/ 和 /home 都能看到内容。 到这里,Laravel 最核心的三件事你都实现了:Composer PSR-4 加载、路由、控制器分发。 第二步:加个 IoC 容器 Laravel 的灵魂其实不是路由,是容器(Container / Service Container)。它做两件事:帮你 new 对象,自动注入构造参数 让你把接口绑定到具体实现,实现依赖倒置最简版本: <?php namespace Framework;use ReflectionClass;class Container { private array $bindings = []; public function bind(string $abstract, callable|string $concrete): void { $this->bindings[$abstract] = $concrete; } public function make(string $class): object { // 1. 有绑定就走绑定 if (isset($this->bindings[$class])) { $c = $this->bindings[$class]; return is_callable($c) ? $c($this) : $this->make($c); } // 2. 反射构造函数依赖,递归 make $ref = new ReflectionClass($class); $ctor = $ref->getConstructor(); if (!$ctor) return new $class(); $args = []; foreach ($ctor->getParameters() as $p) { $type = $p->getType()?->getName(); $args[] = $type ? $this->make($type) : null; } return $ref->newInstanceArgs($args); } }用法: class Logger { public function log(string $msg): void { file_put_contents('log', $msg); } }class UserService { public function __construct(public Logger $logger) {} }$c = new Container(); $user = $c->make(UserService::class); // 自动注入 Logger $user->logger->log("hello");这 40 行代码就是 Laravel Container 的核心思路。真正的 Laravel 加了:方法注入、上下文绑定、单例、tag、event、缓存反射等等,本质还是这个。 后续可以往上加 按需实现,每加一个都是一次深度理解:Middleware — 前后置拦截 Request/Response 对象 View 层(模板引擎或 Blade 简版) ORM(先用 PDO 封个 Query Builder) Event / Listener CLI 命令(symfony/console 或自己写) Config 加载 环境变量(vlucas/phpdotenv)一步步加,你会发现 Laravel 每个模块都能对应上。 一句话总结 Composer PSR-4 + Router + Container,三件套加起来 100 行代码,就是一个能跑的 mini 框架。学 Laravel 前先自己写一遍,再去看源码会很轻松。

Cloudflare 525 SSL handshake failed 排查全流程

某天访问站点,Cloudflare 直接抛了个 525: { "title": "Error 525: SSL handshake failed", "status": 525, "detail": "The SSL/TLS handshake between Cloudflare and the origin server failed." }意思就是——Cloudflare 收到了请求,但它和你的源站之间的 TLS 握手没建起来。不是 Cloudflare 挂了,是源站 SSL 有问题。 常见 8 种原因 1. 源站证书失效 在源站本机测: echo | openssl s_client -servername example.com -connect example.com:443看输出里有没有: Verify return code: 0 (ok)如果出现 certificate has expired,证书过期了,续签或换证书。 2. Nginx 证书和私钥不匹配 分别算 MD5,两边必须一致: openssl x509 -noout -modulus -in cert.pem | md5sum openssl rsa -noout -modulus -in key.pem | md5sum不一致就是配错了。 3. TLS 版本太老 Cloudflare 现在只接受 TLS 1.2 / 1.3。Nginx 配置里明确写清: ssl_protocols TLSv1.2 TLSv1.3;4. Cipher Suite 不兼容 某些老配置写得太激进(HIGH:!aNULL:!MD5 只留少数几个),Cloudflare 找不到共同 cipher。用 nmap 探一下源站支持哪些: nmap --script ssl-enum-ciphers -p 443 <origin-ip>5. Cloudflare 用了 Full (strict) 模式 Dashboard → SSL/TLS → Overview 里检查模式:Flexible:Cloudflare 到源站走 HTTP Full:源站要有证书(不校验) Full (strict):源站必须有效证书、链完整、域名匹配如果是 strict,用 Cloudflare Origin CA 签的证书最省事。 6. 证书链不完整(最常见的坑) 如果 openssl s_client 里出现: verify error:num=20:unable to get local issuer certificate多半是 Nginx 只配了 cert.pem 而不是 fullchain.pem。 正确写法: ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;7. 源站 443 没监听 直接测: curl -vk https://<origin-ip>如果 Connection refused,Nginx 没在这个 IP 上监听 443。防火墙、云安全组同样要放行。 8. IPv6 配置错误 Cloudflare 可能优先走 IPv6,如果 Nginx 没监听或者防火墙漏放,就 525。 dig AAAA example.com出现 AAAA 记录时,Nginx 记得写: listen 443 ssl; listen [::]:443 ssl;快速定位思路 先在源站本机执行: openssl s_client -connect localhost:443 -servername example.com本机就 handshake failure:问题在 Nginx 或证书 本机 OK 但外网 525:问题在 IP 路由 / 防火墙 / IPv6 / Cloudflare 连的不是这台机器再看 Cloudflare 实际解析到哪个 IP: dig A example.com dig AAAA example.com用 --resolve 强制走特定 IP 复现: curl -I https://example.com --resolve example.com:443:<origin-ip>一句话总结 525 = Cloudflare 和源站的 TLS 握手失败。八成落在 证书链不完整 或 Full (strict) 模式下证书不合规,用 openssl s_client 从源站本机开始逐层排查。

Hardhat 本地链卡链?先看是不是关了自动挖矿

Hardhat 本地链跑合约,await tx.wait() 卡住不返回,感觉链死了。看着像 bug,其实基本都是配置问题。 快速定位 一条命令看当前区块号: cast block-number --rpc-url http://127.0.0.1:8545没有 cast 就用 curl: curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'隔几秒再跑一次,区块号不变——链停了。 原因 1:配了 auto: false hardhat.config.js 里如果有: networks: { hardhat: { mining: { auto: false, // 关掉了自动挖矿 // interval: 5000 // 或者只按间隔挖 } } }交易发到内存池后不会被打包,得手动 mine: await network.provider.send("evm_mine");或者恢复自动挖矿: await network.provider.send("evm_setAutomine", [true]);原因 2:nonce 冲突 之前发过一个低 gas 交易占住了某个 nonce,后面的交易全排在它后面: cast nonce 0x<address> --rpc-url http://127.0.0.1:8545解法:发一个相同 nonce、更高 gas 的交易顶掉旧的(cancelTx 模式)。 原因 3:fork 主网时 RPC 挂了 npx hardhat node --fork https://eth-mainnet.g.alchemy.com/v2/xxxAlchemy 超限、RPC 断线、网络超时,Hardhat 表现得就像卡住。开 debug 日志看: DEBUG=hardhat* npx hardhat node会看到"Waiting for chain state"之类的提示。 原因 4:不是链卡,是前端缓存了 Hardhat 每次重启链状态会重置,但前端还拿着旧合约地址、旧 ABI。清理编译产物: rm -rf cache artifacts npx hardhat compile npx hardhat run scripts/deploy.js --network localhost原因 5:evm_mine 返回 0 不等于失败 有人看到: await network.provider.send("evm_mine"); // '0'以为报错了。其实 '0' 是 RPC 的正常返回值,表示成功。区块号会 +1,验证一下: await ethers.provider.getBlockNumber();兜底:一键重置 想快速回到初始状态而不重启进程: await network.provider.request({ method: "hardhat_reset", params: [], });链状态、快照、内存池全清。跑测试之间用这个特别方便。 或者最粗暴——Ctrl+C 结束 npx hardhat node,重新起一个。 一句话总结 Hardhat "卡链" 九成是 auto: false 关了自动挖矿。先 evm_setAutomine [true] 或手动 evm_mine 一下,绝大多数情况就通了。

让 Nginx 直接返回固定 JSON:不走后端的假接口

前端联调时想要一个"始终返回 VIP=1"的假接口,去写后端太重,Nginx 直接顶上就行。 最简写法 location /is_vip { default_type application/json; return 200 '{"code":200,"msg":"操作成功","data":1}'; }三行做完三件事:default_type application/json;——告诉浏览器返回的是 JSON,不是 text/plain return 200 '...'——直接给 HTTP 200 + 固定 body,不用 upstream 外层用单引号包住整个 JSON,内层用双引号,省得转义防止被前端缓存 浏览器可能把这个 200 响应缓存下来,联调时容易被误导。加个 no-store: location /is_vip { default_type application/json; add_header Cache-Control no-store; return 200 '{"code":200,"msg":"操作成功","data":1}'; }跟根据参数返回不同内容 要根据 query string 分支返回,可以用 if + map: map $arg_uid $vip_result { default '{"code":200,"msg":"操作成功","data":0}'; "1" '{"code":200,"msg":"操作成功","data":1}'; "2" '{"code":200,"msg":"操作成功","data":1}'; }location /is_vip { default_type application/json; return 200 $vip_result; }访问 /is_vip?uid=1 返回 VIP=1,其它返回 VIP=0。 需要更复杂的 mock 逻辑就该上 OpenResty 或者写真后端了——Nginx 原生更适合"固定返回"这类死数据。 一句话总结 default_type application/json + return 200 '{...}'。联调阶段最快的 mock 接口,比启 Node/Python 都省事。

ClickHouse system.*_log 表几十 GB?先清后关

线上一台 ClickHouse 跑了两个月,磁盘吃掉几十 GB。查一下: SELECT database, table, formatReadableSize(sum(bytes)) AS size, sum(rows) AS rows FROM system.parts WHERE active GROUP BY database, table ORDER BY sum(bytes) DESC LIMIT 10;结果: system text_log 34.15 GiB 9亿行 system trace_log 11.41 GiB 5亿行 system asynchronous_metric_log 9.36 GiB 347亿行 system metric_log 7.75 GiB 3600万行 system part_log 4.65 GiB 6800万行 system query_log 3.59 GiB 3800万行几乎全是 ClickHouse 自己写的内部监控日志。业务数据加起来才几个 G。 第一步:立刻清理 TRUNCATE 把大头清空: TRUNCATE TABLE system.text_log; TRUNCATE TABLE system.trace_log; TRUNCATE TABLE system.asynchronous_metric_log; TRUNCATE TABLE system.metric_log; TRUNCATE TABLE system.part_log; TRUNCATE TABLE system.processors_profile_log; -- query_log 可以选择保留,用于事后排查 TRUNCATE TABLE system.latency_log;query_log 是"这个 ClickHouse 都处理过什么 SQL"的记录,日常排查很有用,多数情况留着。 清完之后: SYSTEM FLUSH LOGS;再查一次磁盘: du -sh /var/lib/clickhouse/data/system/*空间没立刻回收是正常的。因为:MergeTree 的 parts 只是标记删除,等 merge 文件系统 cache delete_from_disk 有 TTL可以强制一下: OPTIMIZE TABLE system.query_log FINAL;或者最粗暴: sudo systemctl restart clickhouse-server启动时会跳过被标记删除的 parts。 第二步:从根源关掉不需要的日志 清了以后不管,几周后又长回来。要真省心就编辑 config,把不用的日志表直接关掉。 编辑: sudo nano /etc/clickhouse-server/config.d/logs.xml内容: <clickhouse> <!-- 关掉巨吃磁盘的三个 --> <text_log remove="1"/> <trace_log remove="1"/> <asynchronous_metric_log remove="1"/> <metric_log remove="1"/> <part_log remove="1"/> <processors_profile_log remove="1"/> <!-- query_log 保留,但缩短 TTL --> <query_log> <database>system</database> <table>query_log</table> <partition_by>toYYYYMM(event_date)</partition_by> <ttl>event_date + INTERVAL 7 DAY DELETE</ttl> <flush_interval_milliseconds>7500</flush_interval_milliseconds> </query_log> </clickhouse>remove="1" 直接不启用这类表 <ttl> 让 ClickHouse 自动过期删除老数据重启生效: sudo systemctl restart clickhouse-server为什么这些表会爆 asynchronous_metric_log 每秒都在采几百个指标,一天几千万行是正常的。trace_log 记录每个 query 的 profile 事件,非常细。这些表默认全开,对于开发/测试很有用,对生产就是纯磁盘杀手。 生产环境的经验:保留:query_log(+ 7 天 TTL) 可选保留:query_thread_log 排查慢查询用 关掉:text_log、trace_log、asynchronous_metric_log、metric_log、part_log、processors_profile_log如果需要采指标,用 Prometheus 拉 /metrics 接口,别依赖 ClickHouse 内部 metric_log。 顺手加个磁盘水位报警 SELECT name, formatReadableSize(free_space) AS free, formatReadableSize(total_space) AS total, round(free_space / total_space * 100, 1) AS free_percent FROM system.disks;低于 20% 该发告警了。 一句话总结 ClickHouse 磁盘被吃是 system.*_log 表的锅。先 TRUNCATE 清空、再 config 里 remove="1" 关掉大部分、给 query_log 加个 TTL。生产上从第一天就该这么配。

SQLite 存几十 GB 数据的六个优化点

有个错觉——SQLite 只适合小项目。其实很多人低估了它。理论上 SQLite 单库能到 281 TB,实际几十 GB、一亿行都能稳定跑。前提是这几个优化必须做。 先给个使用边界数据量 SQLite 适不适合< 10 GB 完全没问题,甚至比 Postgres 简单10 GB ~ 100 GB 可以,需要认真优化> 100 GB 谨慎,考虑分片 / 换数据库高并发写入 不推荐,SQLite 写是全库锁(WAL 缓解但仍单写)多机共享文件 别玩,NFS 上的 SQLite 特别容易坏适合 SQLite 的典型场景:本地缓存、日志、爬虫存档、IoT 边缘节点、桌面/Electron 应用、单机分析、中小型业务。 1. WAL 模式(必开) PRAGMA journal_mode = WAL;作用:读和写可以并发(默认 journal 会锁全库) 崩溃恢复快 大量写入性能大幅提升一次性开完之后写进库设置,不用每次连接都设。 2. 调 synchronous PRAGMA synchronous = NORMAL; -- 折中,推荐 -- PRAGMA synchronous = OFF; -- 极限性能,接受掉电丢数据FULL(默认)= 每次事务 fsync 磁盘、NORMAL = 检查点时 fsync、OFF = 完全信操作系统。日志/缓存场景 NORMAL 完全够用,性能差好几倍。 3. 批量事务(提升最猛的一条) 单条 insert: for (const row of rows) { db.prepare("INSERT INTO logs VALUES (?, ?)").run(row.a, row.b); }一亿行你等到明年。改成批量: const insert = db.prepare("INSERT INTO logs VALUES (?, ?)"); const many = db.transaction((rows) => { for (const row of rows) insert.run(row.a, row.b); }); many(rows);性能能差 100 ~ 1000 倍。SQLite 每个事务外面套一次 fsync,逐条 insert 就是每行都 fsync。 4. 索引不要滥建 EXPLAIN QUERY PLAN SELECT * FROM logs WHERE user_id = ? AND ts > ?;索引越多,写入越慢(每行 insert 都要维护索引) 只给高频过滤字段建索引 复合索引按 WHERE 里最常见的过滤顺序建:(user_id, ts) 大文本 / JSON 字段别建普通索引,需要就用 FTS55. 分页别用 OFFSET -- 慢,越翻越慢:OFFSET 需要跳过前 N 行 SELECT * FROM logs ORDER BY id LIMIT 20 OFFSET 1000000;-- 快,游标分页:直接从上一页最后一个 id 之后取 SELECT * FROM logs WHERE id > ? ORDER BY id LIMIT 20;学名叫 Keyset Pagination,任何大表分页都该用。 6. 大字段不要塞 blob 图片、视频、大 JSON 直接扔 SQLite 会拖慢一切——包括那些跟大字段无关的查询。 正确做法:文件系统 / 对象存储放实体 SQLite 只存路径 / 元数据或者用 SQLite 官方推荐 的经验值:< 100 KB 存库里更快,> 100 KB 存文件系统更快。 7. 加上几个 PRAGMA PRAGMA cache_size = -100000; -- 100 MB 缓存(负数表示 KB) PRAGMA temp_store = MEMORY; -- 临时表放内存 PRAGMA mmap_size = 268435456; -- 256 MB mmap,减少 syscall PRAGMA busy_timeout = 5000; -- 遇到锁等 5 秒再报错一句话总结 WAL + 批量事务 + Keyset 分页——这三条打下来,几十 GB 的 SQLite 一点都不难。别忘了大字段挪出去、别乱建索引。

用 Cloudflare Worker 做多域名反代 + Error 1003 的解法

一台 VPS 上跑了七八个服务,每个都占一个端口。想用一个域名的多个子域直接映射过去,还不想暴露 IP。上 Nginx 有点重——一个 Cloudflare Worker 就够了。 Worker 代码 思路:读请求的 Host,从 map 里查目标 IP:端口,重新 fetch 转发过去。 export default { async fetch(request) { const host = request.headers.get("host"); const map = { "app.example.com": "http://origin.example.com:51118", "api.example.com": "http://origin.example.com:51117", "admin.example.com":"http://origin.example.com:51116", "shop.example.com": "http://origin.example.com:51115", }; const target = map[host]; if (!target) return new Response("Host not found", { status: 404 }); const url = new URL(request.url); const proxyUrl = target + url.pathname + url.search; const proxied = new Request(proxyUrl, { method: request.method, headers: request.headers, body: (request.method === "GET" || request.method === "HEAD") ? undefined : request.body, redirect: "manual", }); // 把 Host 改成目标域,避免源站按 Host 路由时错乱 proxied.headers.set("host", new URL(target).host); return fetch(proxied); }, };部署路径Cloudflare Dashboard → Workers & Pages → Create Worker 粘代码,Deploy Worker 详情 → Settings → Triggers → Routes 添加路由:app.example.com/*、api.example.com/* 等 对应的 DNS 记录必须 开小黄云(Proxied),否则 Worker 拦不到动态版本(不用维护 map) 如果子域名和端口有规律(比如 app → 51118、api → 51117),可以从子域直接算端口: const portMap = { app: 51118, api: 51117, admin: 51116, shop: 51115 };const sub = host.split(".")[0]; const port = portMap[sub]; if (!port) return new Response("Not found", { status: 404 });const target = `http://origin.example.com:${port}`;以后加子域只改 map 一处。 Error 1003:direct IP access not allowed 第一次绑域名很容易撞到: error code: 1003含义是"直接用 IP 访问 Cloudflare 不允许"。多半是因为你把域名 CNAME 到了 xxx.workers.dev,Cloudflare 就把这当成"客户端在裸 IP 访问"。 正解:不要 CNAME 到 workers.dev,走 Worker 路由。 DNS 那边:加一条 A 记录,IP 随便填(比如 192.0.2.1),一定要开小黄云:类型 名称 内容 代理A app 192.0.2.1 ProxiedA api 192.0.2.1 ProxiedWorker 那边:Settings → Triggers → Routes,加上 app.example.com/* 等。 之后请求走的是 Worker,A 记录里那个 IP 只是 Cloudflare 需要一条 DNS 记录而已,不会真的连过去。 需要 WebSocket / SSE Workers 支持 WebSocket 升级,fetch 里带上 Upgrade 头就能透传: if (request.headers.get("Upgrade") === "websocket") { return fetch(target, request); }SSE (text/event-stream) 靠 Response.body 流式返回就行,Worker 会自动保持长连接。 顺带的好处源站真实 IP 不会暴露给客户端 免费额度撑得住中小站(10 万请求/天) 加 WAF、限流、缓存全在 Cloudflare 控制台一键切换一句话总结 一个 Worker + 一张 host→port 表 = 多域名反代。踩到 Error 1003 别慌,DNS 走 A 记录 + 小黄云 + Worker Route,不要 CNAME 到 workers.dev。

Nginx 反向代理 + 全局跨域:那些看着通了但其实拦了的坑

前后端分离项目上线,前端一直报跨域,可 Nginx 里明明写了 Access-Control-Allow-Origin *。折腾一圈才发现,跨域配置里有几个"看着通了但其实拦了"的隐蔽坑。 最小可用配置 先把最小骨架搭起来——反代到后端 + 全局放开 CORS: server { listen 80; server_name your-domain.com; location / { proxy_pass http://backend.example.com:8002; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS, PATCH" always; add_header Access-Control-Allow-Headers "Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Requested-With,Token" always; if ($request_method = OPTIONS) { return 204; } } }这份能应付纯 API、无登录态的场景。要带 cookie 的话继续往下看,不然会直接被浏览器拦。 坑一:* 和 Allow-Credentials: true 不能共存 很多人从两份教程各抄一段,凑成: add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Credentials true always;浏览器规范明确禁止这种组合,直接跨域失败。 正确做法:把 * 换成 $http_origin 或白名单: add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; add_header Vary Origin always;坑二:Host 被写死 见过这种写法: proxy_set_header Host backend.example.com;后端如果基于 Host 做路由(Spring、Django 之类)或者生成回调 URL,就全乱套。改回: proxy_set_header Host $host;坑三:OPTIONS 只返回 204,没带 CORS 头 写了: if ($request_method = OPTIONS) { return 204; }有些浏览器要求预检响应本身也带跨域头,直接 return 204 就少一批头。解法是让 add_header 放在 if 之前——Nginx 的 add_header 会自动挂到最终响应上,只要不用 error_page 覆盖响应就行。 生产版本(推荐) 同时兼顾 cookie、白名单和预检: map $http_origin $cors_origin { default ""; "~^http://localhost:.*" $http_origin; "~^https://your-frontend\.com$" $http_origin; }server { listen 80; server_name your-domain.com; location / { proxy_pass http://backend.example.com:8002; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS, PATCH" always; add_header Access-Control-Allow-Headers "Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Requested-With,Token" always; add_header Vary Origin always; if ($request_method = OPTIONS) { return 204; } } }map 是精髓:预定义的域名才回响原 Origin,其它的会拿到空字符串,浏览器就自然拒绝。 一张表决定怎么选场景 推荐写法纯 API,无 cookie Allow-Origin *带 cookie / token map + $http_origin 白名单本地开发临时放开 Allow-Origin $http_origin一句话总结 CORS 卡住 90% 的时候都是这三件事:* 撞 Credentials、Host 写死、OPTIONS 没配好。抄配置前先想清楚要不要带 cookie。

Cloudflare Workers 入门:从 hello world 到 KV / D1

Cloudflare Workers 本质上是"边缘 JS/TS 函数"——写一段代码,部署到全球 300 多个节点,用户就近访问。适合做 API 代理、Webhook、AI 接口中转、鉴权网关,比自建 Nginx 或买 VPS 省事。 项目起步 一个命令拉起脚手架: npm create cloudflare@latest my-worker cd my-worker npm run dev # 本地起 wrangler dev npm run deploy # 部署到生产第一次部署会让你登录: npx wrangler login最小骨架 新版模块化写法: export default { async fetch(request, env, ctx) { return new Response("hello world"); }, };访问 https://<name>.<account>.workers.dev 就能看到。 路由分发 原生没有路由框架,URL 手动分: export default { async fetch(request) { const url = new URL(request.url); if (url.pathname === "/api/user") { return Response.json({ name: "alice", age: 18 }); } if (url.pathname === "/health") { return new Response("ok"); } return new Response("Not Found", { status: 404 }); }, };要更完善的路由体验就上 Hono: import { Hono } from "hono"; const app = new Hono(); app.get("/api/user", (c) => c.json({ name: "alice" })); export default app;读取请求 // query string const url = new URL(request.url); const name = url.searchParams.get("name");// JSON body const data = await request.json();// 表单 const form = await request.formData();转发外部 API(最常见用法) export default { async fetch(request, env) { const resp = await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${env.OPENAI_API_KEY}` }, }); return new Response(resp.body, resp); }, };常见用途:藏 API Key、破跨域、加限流、加缓存、AI 接口中转。Key 千万别写死在代码里,走 wrangler secret。 KV:类 Redis 键值 wrangler.toml: [[kv_namespaces]] binding = "MY_KV" id = "xxxxxxxx"代码: await env.MY_KV.put("username", "alice"); const v = await env.MY_KV.get("username"); await env.MY_KV.delete("username");KV 是最终一致(全球复制有几秒延迟),做缓存 / 配置 / session 都很合适。 D1:SQLite [[d1_databases]] binding = "DB" database_name = "mydb" database_id = "xxxxxxxx"用法: const rows = await env.DB .prepare("SELECT * FROM users WHERE id = ?") .bind(userId) .all();参数化查询是标配,别拼字符串——同样有 SQL 注入。 环境变量 vs Secret 普通变量写 wrangler.toml: [vars] APP_NAME = "my-app"敏感 key 用 secret,不进代码库: npx wrangler secret put OPENAI_API_KEY两种都通过 env.XXX 访问。 免费额度和局限免费 10 万次请求/天,付费 10 美元/月起 单请求 CPU 时间限制(免费 10ms,付费 50ms+,最高 5 分钟) 不能开 socket、不能持久保存本地文件 Node.js 内置模块要靠 nodejs_compat 兼容标志一句话总结 Workers = 全球边缘 JavaScript。npm create cloudflare@latest 起步,fetch 转发是 90% 用例,配上 KV/D1 就能扛住一个中等 API 项目。

Node 内置 SQLite 报 statement has been finalized 的原因

Node 22 开始有了内置的 node:sqlite,同步 API 用起来非常顺。但启动时挂: ExperimentalWarning: SQLite is an experimental feature Failed to start server Error: statement has been finalized at readDeviceStates (src/lib/database.ts:76:38) ... code: 'ERR_INVALID_STATE'这个错的字面意思很明确:你在一个已经 finalize() 过的 prepared statement 上继续调用 all() / get() / iterate()。 三种最常见的成因 1. 手动过早 finalize const stmt = db.prepare("SELECT * FROM devices"); try { return stmt.all(); } finally { stmt.finalize(); }看上去正确。但如果 stmt.all() 返回的是迭代器(iterate())而不是数组(all()),外层还在懒消费,finalize 一走就崩。用 all() 拿到具体数组的写法没这个问题。 2. 用了 using 自动 finalize(Node 22 的新语法) export function readDeviceStates() { using stmt = db.prepare("SELECT * FROM device_states"); return stmt.iterate(); // ← 迭代器,出了作用域 stmt 就被 finalize 了 }using 借用了 TC39 的 Explicit Resource Management 提案,出作用域时会自动调 [Symbol.dispose](),也就是 finalize()。返回迭代器给外部继续用,外部一 next() 就炸。 规则:using + iterate() 不能混,要么用 all() 一次拿全部再返回,要么调用方也 using。 3. 模块级缓存了 stmt // 模块顶部 const readStmt = db.prepare("SELECT * FROM device_states");export function readDeviceStates() { return readStmt.all(); }看着挺合理——避免每次 prepare 的开销。但代码其它地方可能:单元测试用 db.close() 关掉了数据库(stmt 一起 finalize) 有热重载/HMR 重新 import 了模块(旧 stmt 引用还留着) 显式调用了 readStmt.finalize() 想清理,然后忘了之后再走这个函数就 ERR_INVALID_STATE。 推荐写法 每次现 prepare,让 GC 管理: export function readDeviceStates() { const stmt = db.prepare("SELECT * FROM device_states"); return stmt.all(); // 立刻取完,不返回 iterator }现代 SQLite 的 prepare 开销很小,不需要为性能提前优化。 要缓存的话,配套复用而不是复用 + finalize: const cache = new Map<string, ReturnType<typeof db.prepare>>();function s(sql: string) { let stmt = cache.get(sql); if (!stmt) { stmt = db.prepare(sql); cache.set(sql, stmt); } return stmt; }// 用: s("SELECT * FROM device_states").all();不在缓存生命周期外手动 finalize。数据库 close 时统一 cache.clear()。 要用 using 就一次性拿完数据: export function readDeviceStates() { using stmt = db.prepare("SELECT * FROM device_states"); return stmt.all(); // 数组,可以安全跨作用域 }一句话总结 statement has been finalized = 你正在用一个死掉的 stmt。别返回迭代器给作用域外、别在模块级缓存又手动 finalize。改成"每次 prepare + all()"最省心。

Go 后端接入 Web3 钱包登录:nonce + personal_sign 验签

Go 后端接 Web3 钱包登录,标准流程只有四步:后端下发 nonce 前端钱包用 personal_sign 签 "Login\nNonce: xxx" 后端 recover 出地址,比对是不是同一个 通过后签发 JWT / session省心的做法是前端签名 + 后端验签,别在后端搞私钥。 生成 nonce 一次性、5 分钟过期、写 Redis: package authimport ( "crypto/rand" "encoding/hex" )func GenerateNonce() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) }Redis key:wallet:nonce:0xabc... → <nonce>,TTL 5 分钟。 前端签名 const message = `Login\nNonce: ${nonce}`; const signature = await ethereum.request({ method: "personal_sign", params: [message, address], });要点:用 personal_sign,不要自己算 hash 消息用明文发给 MetaMask,它会自动加 EIP-191 前缀Go 侧验签 package authimport ( "fmt" "strings" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" )func VerifySignature(address, message, sigHex string) bool { sigHex = strings.TrimPrefix(sigHex, "0x") sig := common.FromHex(sigHex) if len(sig) != 65 { return false } // 关键点 1:v 值处理(MetaMask 返回 27/28,crypto 库要 0/1) if sig[64] != 27 && sig[64] != 28 { return false } sig[64] -= 27 // 关键点 2:EIP-191 前缀 prefixed := fmt.Sprintf( "\x19Ethereum Signed Message:\n%d%s", len(message), message, ) hash := crypto.Keccak256Hash([]byte(prefixed)) pubKey, err := crypto.SigToPub(hash.Bytes(), sig) if err != nil { return false } recovered := crypto.PubkeyToAddress(*pubKey) return strings.EqualFold(recovered.Hex(), address) }登录 handler type LoginReq struct { Address string `json:"address"` Signature string `json:"signature"` }func Login(c *gin.Context) { var req LoginReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "bad request"}) return } key := "wallet:nonce:" + strings.ToLower(req.Address) nonce, err := redisClient.Get(ctx, key).Result() if err != nil { c.JSON(401, gin.H{"error": "nonce expired"}) return } message := "Login\nNonce: " + nonce if !VerifySignature(req.Address, message, req.Signature) { c.JSON(401, gin.H{"error": "invalid signature"}) return } // 防重放:立刻删掉 nonce redisClient.Del(ctx, key) token, _ := GenerateJWT(req.Address) c.JSON(200, gin.H{"token": token}) }三个必踩的坑 1. 少了 EIP-191 前缀 MetaMask 的 personal_sign 会自动加: "\x19Ethereum Signed Message:\n" + len(msg) + msg后端验签必须手动加回这个前缀再算 keccak。少了直接验不过。 2. v 值 27/28 vs 0/1 MetaMask 返回的签名最后一个字节(v)是 27 或 28(EIP-155 前的格式)。go-ethereum 的 crypto.SigToPub 要求是 0 或 1。必须减 27。 3. 忘了防重放 nonce 验完不删,攻击者抓包重放就能永远登进来。验签成功后立刻 redis.Del。 建议直接上 SIWE Sign-In with Ethereum(EIP-4361)是现在的标准,消息格式包括域名、URI、Chain ID、Issued At 等,钱包会更友好地显示: example.com wants you to sign in with your Ethereum account: 0xabc...123URI: https://example.com Version: 1 Chain ID: 1 Nonce: 8a2c... Issued At: 2026-05-10T16:31:04ZGo 侧现成库: go get github.com/spruceid/siwe-goimport siwe "github.com/spruceid/siwe-go"msg, err := siwe.ParseMessage(rawMessage) if err != nil { ... }if _, err := msg.Verify(signature, &domain, &nonce, nil); err != nil { // 验签失败 }SIWE 帮你处理消息构造、时效、chain id、防重放,比手写靠谱。 前端推荐栈wagmi + viem:现在 EVM dApp 的事实标准 直接 useSignMessage,签名一行搞定 不用自己处理 window.ethereum一句话总结 Web3 登录 = nonce + personal_sign + Go recover 比地址。三个坑:加 EIP-191 前缀、v 值减 27、nonce 用完立刻删。新项目直接上 SIWE,别自己拼消息格式。

Python 移动 / 复制目录的正确写法:shutil 全场景速查

Python 里"移动整个目录"或"把文件全复制到另一处"这类操作,全部靠 shutil。列几个最常用的模板。 移动整个目录 99% 场景直接: import shutilshutil.move(r"C:\source_dir", r"D:\target_dir")行为:目标不存在 → 直接改名/移动 目标已存在 → 源被移到 目标\源目录名 同盘 是秒移(本质是 rename) 跨盘 会退化成复制 + 删除想强制覆盖已有目标: import os, shutilsrc = r"C:\source_dir" dst = r"D:\target_dir\source_dir"if os.path.exists(dst): shutil.rmtree(dst) shutil.move(src, dst)复制整个目录(含子目录) Python 3.8+: import shutilshutil.copytree( r"C:\source_dir", r"D:\target_dir", dirs_exist_ok=True, # 目标已存在时合并写入 )会连带子目录、文件属性一起复制。 只复制当前目录里的文件(跳过子目录) import os, shutilsrc = r"C:\source_dir" dst = r"D:\target_dir" os.makedirs(dst, exist_ok=True)for name in os.listdir(src): s = os.path.join(src, name) if os.path.isfile(s): shutil.copy2(s, os.path.join(dst, name))copy2 会保留 mtime、权限,比 copy 更完整。 只复制某类文件 import os, shutilfor name in os.listdir(src): if name.endswith(".txt"): shutil.copy2( os.path.join(src, name), os.path.join(dst, name), )递归复制文件(扁平化,不保留目录结构) import os, shutilfor root, _, files in os.walk(src): for f in files: shutil.copy2(os.path.join(root, f), os.path.join(dst, f))注意同名文件会互相覆盖,要保结构就直接 copytree。 用 pathlib 版本(更现代) from pathlib import Path import shutilsrc = Path(r"D:\A\deep\folder") dst = Path(r"D:\B\deep\folder")if dst.exists(): shutil.rmtree(dst) dst.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(src, dst)Path 的 parent / mkdir(parents=True) 组合特别适合处理"目标父目录还没创建"的情况。 不要用 os.rename os.rename(src, dst)跨磁盘直接报错 不能覆盖已有目录除非明确是"同盘、无冲突",否则直接上 shutil.move。 一句话总结 移动用 shutil.move,整目录复制用 shutil.copytree(dirs_exist_ok=True)。其它场景在这两个基础上按需组合。