跳到主要内容

Chrome 扩展数据直写生产库?加个 DRY-RUN 开关安全调试

· 阅读需 4 分钟

TL;DR

Chrome 扩展在开发调试时,每次测试采集都会把数据写进生产数据库。用三层 DRY-RUN 开关解决:.env.development 设环境变量 → 客户端读取后给请求加 X-Dry-Run Header → 服务端拦截该 Header 返回数据预览,不执行写入。生产环境不设该变量,完全不受影响。


问题现象

Chrome 扩展采集电商数据后通过 API 提交到后端。开发调试阶段,每触发一次采集就往生产数据库写一条记录。测试几次后,数据库里全是脏数据,影响线上数据准确性。

没有"只看不写"的开关,要么注释掉提交代码(容易忘改回来),要么连着生产库调试(脏数据不可避免)。


根因

Chrome 扩展的开发环境和生产环境共用同一个 API 端点。fetch 请求里没有任何标记区分"这是一次调试提交"还是"这是一次真实提交"。后端收到请求就执行写入,无法区分意图。

开发时需要一个"预览模式"——看到数据会提交什么,但不实际写入。


解决方案

三层实现:环境变量 → HTTP Header → 服务端拦截。

Step 1:声明环境变量类型

// client/src/env.d.ts
interface ImportMetaEnv {
// ... 其他变量
readonly VITE_INQUIRY_DRY_RUN?: string;
}

Step 2:开发环境配置

# client/.env.development(仅开发环境)
VITE_INQUIRY_DRY_RUN=true
# client/.env.production(不设置此变量)
# 生产环境始终走真实写入

Step 3:客户端读取环境变量,条件加 Header

在 Service Worker(background script)中,读取环境变量并给请求加标记:

// background.ts
chrome.storage.local.get('sessionToken', (result) => {
const sessionToken = result.sessionToken;
if (!sessionToken) return;

// 读取 DRY-RUN 开关
const dryRun = import.meta.env.VITE_INQUIRY_DRY_RUN === 'true';

const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`,
};

// DRY-RUN 模式加标记 Header
if (dryRun) {
headers['X-Dry-Run'] = 'true';
}

fetch(`${baseURL}/inquiry/collect`, {
method: 'POST',
headers,
body: JSON.stringify({ memberId, rows }),
});
});

WXT/Vite 在构建时将 import.meta.env.VITE_INQUIRY_DRY_RUN 内联替换为字符串。开发构建(.env.development)值为 "true",生产构建(.env.production)值为 undefined=== 'true' 自然为 false

Step 4:服务端拦截 DRY-RUN 请求

// server/src/functions/analytics/inquiry.ts
router.post('/collect', async (req, res) => {
// ... 认证、memberId 映射等前置逻辑

const dryRun = req.headers['x-dry-run'] === 'true';

if (dryRun) {
// 不写入数据库,返回数据预览
return res.json({
code: 200,
message: 'dry-run ok',
data: {
dryRun: true,
shop_id: match.shop_id,
platform_id: match.platform_id,
memberId,
rowCount: rows.length,
sampleRows: rows.slice(0, 3),
},
timestamp: Date.now(),
});
}

// 正常流程:写入数据库
const result = await analyticsClient.post('/internal/data/import/inquiry', payload);
res.json({ code: 200, data: result });
});

服务端在 DRY-RUN 模式下仍然执行认证和数据校验(确保数据格式正确),只是跳过最终的数据库写入。这样既能验证数据格式,又不产生脏数据。


注意事项

不要在生产 .env 中设置 DRY-RUN 变量

.env.production 不设置 VITE_INQUIRY_DRY_RUN,生产构建中该变量为 undefined,条件判断自然为 false。永远不要在生产配置中开启此开关。

客户端要检查 dryRun 标志

DRY-RUN 模式下服务端返回 200 状态码和 { dryRun: true, data: {...} }。客户端代码如果只检查 code === 200 会导致误判为"写入成功"。检查 response.data.dryRun 区分真实写入和预览。

DRY-RUN 仍然执行认证和数据校验

不要在认证之前就拦截 DRY-RUN 请求。保持完整的请求链路(认证 → 校验 → 拦截),这样能验证请求格式和权限是否正确,只是跳过最后一步写入。


Chrome 扩展 Service Worker 读不到登录态?跨上下文 Token 同步方案

· 阅读需 4 分钟

TL;DR

Chrome 扩展使用 sidepanel 作为 UI 入口,用户登录后 token 存入 localStorage。但 Service Worker(background script)没有 localStorage,调用直接抛 ReferenceError。解决方案:sidepanel 登录后通过 chrome.runtime.sendMessage 将 token 同步给 Service Worker,由 Service Worker 写入 chrome.storage.local。两边各取所需——sidepanel 读 localStorage,Service Worker 读 chrome.storage.local


问题现象

用户在 sidepanel 登录成功,localStorage 中写入了 sessionToken。但当 Service Worker(background script)尝试读取 token 发起 API 请求时:

ReferenceError: localStorage is not defined
at getAuthToken (background.js:42)
at submitCollectedData (background.js:87)

登录在 sidepanel 没问题,但所有从 Service Worker 发出的认证请求全部失败。


根因

Chrome 扩展有多个执行上下文,各自拥有的 API 不同:

上下文windowlocalStoragechrome.storagechrome.runtime
Sidepanel / Popup可能 undefined
Content Script有(隔离)
Service Worker

关键限制:

  • Service Worker 没有 windowdocumentlocalStorage,只能用 chrome.storage API
  • Sidepanel 在 DevTools 上下文中 chrome.storage 可能是 undefined

登录流程在 sidepanel 将 token 存入 localStorage。Service Worker 尝试从 localStorage 读取 → ReferenceError


解决方案

架构

Sidepanel (login)
├→ localStorage.setItem(token) ← sidepanel 读取
└→ chrome.runtime.sendMessage(syncToken)
└→ Service Worker
└→ chrome.storage.local.set(token) ← Service Worker 读取

Token 存在两个地方:localStorage(sidepanel 读取)和 chrome.storage.local(Service Worker 读取)。登录和登出时通过消息机制保持两边同步。

Step 1:登录函数 — 双写

登录成功后,先写 localStorage(sidepanel 可用),再通过消息同步给 Service Worker:

// auth.ts — 登录成功后
localStorage.setItem('sessionToken', token);
localStorage.setItem('userInfo', JSON.stringify(user));
localStorage.setItem('subscriptionInfo', JSON.stringify(subscription));

// 同步到 chrome.storage.local(通过 Service Worker)
try {
chrome.runtime.sendMessage({
action: 'syncToken',
token,
user,
subscription,
});
} catch (e) {
// 非扩展上下文(单元测试、普通网页),忽略
}

try/catch 是必要的——在非扩展上下文中 chrome.runtime.sendMessage 会抛异常。

Step 2:Service Worker — 消息处理

在 background script 中监听消息,将 token 写入 chrome.storage.local

// background.ts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// 同步 token:sidepanel → chrome.storage.local
if (request.action === 'syncToken') {
const { token, user, subscription } = request;
const data: Record<string, string> = {};
if (token) data.sessionToken = token;
if (user) data.userInfo = typeof user === 'string' ? user : JSON.stringify(user);
if (subscription)
data.subscriptionInfo =
typeof subscription === 'string' ? subscription : JSON.stringify(subscription);

chrome.storage.local.set(data, () => {
console.log('[syncToken] token synced to chrome.storage.local');
sendResponse({ success: true });
});
return true; // 异步 sendResponse 必须返回 true
}

// 登出时清除 token
if (request.action === 'clearToken') {
chrome.storage.local.remove(
['sessionToken', 'userInfo', 'subscriptionInfo'],
() => {
console.log('[clearToken] token cleared from chrome.storage.local');
sendResponse({ success: true });
}
);
return true;
}
});

Step 3:Service Worker 从 chrome.storage.local 读取 token

// Before(Service Worker 中报错):
const sessionToken = localStorage.getItem('sessionToken');

// After(正确方式):
chrome.storage.local.get('sessionToken', (result) => {
const sessionToken = result.sessionToken;
if (!sessionToken) {
console.warn('No session token found');
return;
}
// 使用 token 发起 API 请求
fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${sessionToken}` },
});
});

Step 4:登出清除两边

// auth.ts
function clearAuthData(): void {
localStorage.removeItem('sessionToken');
localStorage.removeItem('userInfo');
localStorage.removeItem('subscriptionInfo');
try {
chrome.runtime.sendMessage({ action: 'clearToken' });
} catch (e) {
// 非扩展上下文,忽略
}
}

注意事项

双存储必须同步

Token 存在两个地方:localStorage(sidepanel 读)+ chrome.storage.local(Service Worker 读)。登录和登出都必须同时操作两边,否则状态不一致。

onMessage 中 return true 不能省

chrome.runtime.onMessage 监听器中使用异步 sendResponse(如 chrome.storage.local.set 的回调)时,必须 return true。否则消息通道提前关闭,sendResponse 调用无效。

不要在 sidepanel 直接用 chrome.storage

DevTools 上下文中 chrome.storage 可能是 undefined,无法直接写入。用 localStorage + 消息转发是最可靠的方案。


数据库认证失败?WSL 下 Docker 静默占用了你的端口

· 阅读需 3 分钟

TL;DR

WSL2 环境下 Docker Desktop 静默占用 5432 端口。SSH 隧道 localhost:5432 实际连接的是 Docker 容器内的 PostgreSQL,而非远程服务器。密码认证失败的报错具有误导性——密码没错,连的实例错了。解决方案:隧道改用本地 5433 端口,配合 .env.local 隔离开发配置。


问题现象

通过 SSH 隧道连接远程 PostgreSQL 时报错:

PostgresError: password authentication failed for user "postgres"
severity: 'FATAL'
code: '28P01'
file: 'auth.c'
line: '329'
routine: 'auth_failed'

隧道命令看起来正常建立:

ssh -L 5432:localhost:5432 -L 3003:localhost:3003 user@server -N &

隧道没有报错,但连接后始终提示密码错误。确认密码完全正确,远程服务器上直接登录没有问题。


根因

WSL2 网络架构中,Docker Desktop 会在 WSL2 内部创建虚拟网络接口。当 Docker 容器映射了 5432:5432 时,Docker 在 WSL2 网络层监听 5432 端口。

SSH 隧道 -L 5432:localhost:5432 的含义是:将本地 5432 端口转发到远程服务器的 5432。但本地 5432 已被 Docker 占用,隧道的绑定静默失败——连接直接被 Docker 拦截。

结果:localhost:5432 实际连接到 Docker 容器内的 PostgreSQL。该实例有不同的用户密码配置,因此报 password authentication failed

这个错误的迷惑性:报错信息是"密码错误",不是"端口被占用"或"隧道失败"。开发者会反复确认密码,而真正的问题是连错了机器。


解决方案

Step 1:确认端口冲突

# WSL2 内检查
ss -tlnp | grep 5432

WSL 内可能查不到(Docker 端口从 Windows 侧映射),去 Windows PowerShell 交叉验证:

netstat -ano | findstr :5432

看到 docker-proxy 进程占用 5432,冲突确认。

Step 2:改用其他本地端口建隧道

# Before: 本地 5432(与 Docker 冲突)
ssh -L 5432:localhost:5432 user@server -N &

# After: 本地 5433(无冲突)
ssh -L 5433:localhost:5432 user@server -N &

-L 格式是 本地端口:远程主机:远程端口。只改本地端口,远程不受影响。

Step 3:创建 .env.local 隔离开发配置

# server/.env.local(加入 .gitignore)
DATABASE_URL=postgresql://postgres:your_password@localhost:5433/your_db

Step 4:dotenv 优先加载 .env.local

// Before:
import 'dotenv/config';

// After:
import dotenv from 'dotenv';
import { existsSync } from 'fs';
import { resolve } from 'path';

if (existsSync(resolve(__dirname, '../.env.local'))) {
dotenv.config({ path: resolve(__dirname, '../.env.local') });
} else {
dotenv.config();
}

开发环境用 .env.local(5433),生产继续用 .env(5432),互不干扰。


注意事项

报错信息可能误导你

password authentication failed 不一定代表密码错误。连到错误的实例(如 Docker 内的 PostgreSQL),该实例没有对应账户,同样报这个错。密码确认无误但仍失败时,优先排查连接目标。

不要改应用代码中的默认端口

在隧道层面换本地端口,配合 .env.local 管理配置。不要把代码里的默认端口改成 5433——那会影响生产和容器内连接。环境差异通过环境变量解决。

WSL 端口排查要查 Windows 侧

Docker Desktop 在 WSL2 模式下的容器端口映射直接出现在 Windows 网络层,WSL 内 sslsof 查不到 PID。遇到莫名端口占用,去 Windows 侧 netstat -ano 定位。


容器端口在 WSL2 里访问不到?Docker Desktop 的 network_mode:host 陷阱

· 阅读需 4 分钟

在为客户搭建 AI 数据分析平台(Airflow + PostgreSQL)的本地开发环境时遇到此问题,记录根因与解法。

TL;DR

Docker Desktop for Windows (WSL2 backend) 下,network_mode: host 的容器端口无法从 WSL2 宿主机访问。容器内 ss 显示端口在监听,但宿主机 curl localhost:PORT connection refused。原因是 host 模式共享的是 Docker 内部工具 VM 的网络,不是 WSL2 的网络。解法:override 文件中用 network_mode: !reset 移除 host 模式,改用 bridge + external network + 端口映射。

问题现象

项目使用 docker-compose.yml 部署 Airflow,base 配置:

x-airflow-common:
&airflow-common
image: airflow-ai-dag:latest
network_mode: host # 服务器上正常,WSL2 上出问题
volumes:
- ..:/opt/airflow/project

services:
airflow-webserver:
<<: *airflow-common
command: webserver
environment:
- AIRFLOW__WEBSERVER__WEB_SERVER_PORT=8082

容器启动正常,日志确认端口监听:

[INFO] Listening at: http://0.0.0.0:8082

但宿主机完全不可达:

$ ss -tlnp | grep 8082
# 空,无监听

$ curl localhost:8082/health
curl: (7) Failed to connect to localhost port 8082

而同一环境下,pgAdmin 容器用 -p 8080:80 端口映射,localhost:8080 正常访问。

根因

Docker Desktop for Windows 的网络架构与 Linux 原生 Docker 不同:

Windows 浏览器

WSL2 宿主机(你操作的终端)
↕ Docker Desktop 管道
Docker Desktop 工具 VM ← network_mode: host 的 "host" 是这里

容器

Linux 服务器上 network_mode: host 直接共享宿主机网络,容器端口 = 宿主机端口。但在 Docker Desktop WSL2 环境下:

  • host 模式的 "host" = Docker Desktop 工具 VM,不是 WSL2
  • 容器内 /proc/net/tcp 有端口监听记录
  • WSL2 宿主机的 /proc/net/tcp 没有对应记录
  • -p 端口映射的容器不受影响(Docker Desktop 会自动转发)

验证方法: 对比容器内外网络命名空间:

# 容器内:8082 (hex 1F92) 在监听
$ docker exec webserver grep '1F92' /proc/net/tcp
4: 00000000:1F92 00000000:0000 0A ...

# WSL2 宿主机:8082 不存在
$ grep '1F92' /proc/net/tcp
# 无输出

解决方案

步骤 1:创建 docker-compose.override.yml

!reset 移除 base 的 network_mode: host(需要 Compose v2.24+):

# docker-compose.override.yml
services:
airflow-webserver:
network_mode: !reset # 移除 base 的 host 模式
networks:
- app_network # 加入外部网络
ports:
- "8082:8082" # 端口映射
environment:
- DB_HOST=postgres-db # 用容器名连接数据库

airflow-scheduler:
network_mode: !reset
networks:
- app_network
environment:
- DB_HOST=postgres-db

networks:
app_network:
external: true # 引用已存在的外部网络

步骤 2:确保数据库容器在同一网络

# 查看数据库容器所在网络
$ docker inspect postgres-db --format '{{json .NetworkSettings.Networks}}'
# {"app_network": {...}}

# 确认外部网络存在(不存在则创建)
$ docker network create app_network # 或已存在则跳过

步骤 3:重启生效

$ docker compose down
$ docker compose up -d

验证:

$ ss -tlnp | grep 8082
LISTEN 0 4096 *:8082 *:*

$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/health
200

关键点:!reset 必须放在服务定义里

以下写法不生效

# ❌ 错误:放在 anchor 内
x-airflow-local:
&airflow-local
network_mode: !reset # 不生效!
networks:
- app_network

services:
airflow-webserver:
<<: *airflow-local # merge 时 base 的 network_mode:host 仍在

正确写法:

# ✅ 正确:每个服务单独写
services:
airflow-webserver:
network_mode: !reset # 必须在这里
networks:
- app_network

注意事项

  • network_mode: !reset 需要 Docker Compose v2.24+,用 docker compose version 确认
  • !reset 只能移除标量字段(如 network_mode),列表和字典字段不支持
  • 如果 base 使用了 YAML anchor(<<: *anchor),!reset 必须写在服务定义层,不能写在另一个 anchor 里
  • 改为 bridge 模式后,容器间不能用 localhost 互相访问,必须用容器名或加入同一 Docker network
  • network_modenetworks 互斥,同时存在会报 mutually exclusive 错误

修改 WordPress Block Theme 不生效?FSE 开发 5 大难题排查指南

· 阅读需 8 分钟

在为客户开发 WordPress Block Theme 时反复遇到这五个问题,每次排查都花了不少时间。整理成指南,帮助同样在做 FSE 开发的同学快速定位。

TL;DR

五个问题按频率排序:文件修改不生效(数据库缓存覆盖文件)、块嵌套错乱(注释未关闭)、子主题内容不渲染(缺少 post-content 块)、SVG 图标消失(WP_Filesystem 被插件污染)、WP-CLI 邮件失败(SMTP 插件在命令行不生效)。每个场景都给出可直接复用的排查命令。

场景一:改了主题文件,页面没变化

问题现象

修改了主题目录下的 theme.jsontemplates/*.htmlparts/*.html,刷新页面无变化。甚至 git pull 更新了代码,前端仍然显示旧样式。

根因

FSE 主题的模板和全局样式会被 Site Editor 保存到数据库——wp_templatewp_template_partwp_global_styles 三种自定义文章类型。WordPress 读取时数据库版本优先于文件版本。即使你改了文件,只要数据库里有对应记录,就会用数据库的。

解决方案

不同文件类型对应不同的清理方式:

修改内容清理方式
templates/*.htmlwp_template
parts/*.htmlwp_template_part
theme.jsonwp_global_styles + 缓存
patterns/*.php直接生效,无需清理

一键清理所有数据库模板缓存:

# 本地 Docker 环境
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_template --format=ids --allow-root) --force --allow-root'
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_template_part --format=ids --allow-root) --force --allow-root'
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_global_styles --format=ids --allow-root) --force --allow-root'
docker exec wp_cli wp cache flush --allow-root

如果 theme.json 修改后仍不生效,先验证 JSON 格式是否有语法错误(如尾随逗号,JSON 规范不允许):

docker exec wp_cli wp eval 'echo json_encode(json_decode(file_get_contents(get_template_directory() . "/theme.json")));' --allow-root
# 返回空字符串 → JSON 有语法错误

注意事项

  • 开发期禁止在 Site Editor 中保存,避免产生数据库覆盖
  • 生产环境清理前确认 Site Editor 中没有自定义修改需要保留
  • patterns/*.php 不受此问题影响——Pattern 注册走 PHP 代码,不经过数据库
  • Site Editor 保存的 wp_global_styles 如果 JSON 损坏,会导致全站 WP_Theme_JSON_Resolver 解析错误,表现为所有页面样式崩溃

场景二:Site Editor 显示"尝试恢复",页面布局全乱了

问题现象

Site Editor 中 Pattern 显示"尝试恢复"(Attempt Recovery),保存后页面布局完全错乱。某些块被错误地嵌套在其他块内部,层级关系与源码不一致。

根因

WordPress 块编辑器使用 HTML 注释标记块的边界:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- 内容 -->
<!-- /wp:group -->

当容器块(如 wp:groupwp:columns)缺少关闭注释 <!-- /wp:group --> 时,Gutenberg 的 parse_blocks() 会将后续所有块视为该容器的子块。后果:

  1. 父块的 save 输出为空
  2. 触发 Block validation failed
  3. 后续所有块的嵌套关系全部错位

解决方案

排查:在浏览器控制台检查块树层级:

wp.data.select('core/block-editor').getBlocks()

检查返回的块树结构,确认每个容器块的 innerBlocks 是否符合预期。如果一个 group 块内部包含了不应该在里面的块,大概率是前面某个容器缺少关闭注释。

修复:打开 Pattern 源文件,逐个检查每个 <!-- wp:xxx --> 都有对应的 <!-- /wp:xxx -->。建议在编辑器中搜索 <!-- wp:<!-- /wp:,计数确认数量一致。

预防技巧

使用支持括号匹配的编辑器(如 VS Code),配合 Block Comment 高亮插件,可以在编写时立即发现未关闭的注释。对于复杂的 Pattern,建议先写骨架结构(所有开闭注释配对),再填充内容。

场景三:子主题覆盖模板后,编辑器内容消失了

问题现象

创建子主题覆盖父主题模板后,在 WordPress 页面编辑器中输入的内容(文本、图片等)在前端完全空白。但模板文件中硬编码的 Pattern(如 hero 区域、CTA 区块)正常显示。

根因

FSE 模板通过 <!-- wp:post-content /--> 块来渲染页面编辑器中的 post_content。如果子主题覆盖的模板文件中没有这个块,WordPress 不知道在哪里输出页面内容。

结果就是:模板的固定结构(header、hero、sidebar)正常显示,但编辑器里写的内容全部丢失。

解决方案

确保子主题模板包含 post-content 块:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">

<!-- 模板固定结构(hero、sidebar 等) -->

<!-- wp:post-content {"layout":{"type":"constrained"}} /-->

<!-- 更多固定结构(CTA、footer 引用等) -->

</div>
<!-- /wp:group -->

排查"改了没效果"时,按以下顺序确认:

  1. 模板文件是否包含 <!-- wp:post-content /-->
  2. 你修改的是模板文件还是页面内容——两者控制不同的内容区域
  3. 模板中内联的 cover block 由模板文件控制,与数据库 post_content 无关

场景四:SVG 图标突然消失,但文件还在

问题现象

主题中使用 WP_Filesystem 读取 SVG 图标文件,突然所有 SVG 图标消失。直接访问 SVG 文件 URL 返回正常内容,但页面上图标位置是空的。

根因

WordPress 的 $wp_filesystem 全局变量默认使用 WP_Filesystem_Direct(直接读写本地文件)。某些插件(备份、安全类)在初始化时会将 $wp_filesystem 替换为 WP_Filesystem_ftpsocketsWP_Filesystem_SSH2

FTP/SSH 适配器通过远程连接读取文件,对本地路径(如 /var/www/html/wp-content/themes/...)无法正确访问,返回空字符串。由于替换发生在全局作用域,所有使用 WP_Filesystem 的主题和插件代码都受影响。

解决方案

第一步——诊断:检查当前 $wp_filesystem 的实际类型:

# 本地 Docker 环境
docker exec wp_cli wp eval 'global $wp_filesystem; echo get_class($wp_filesystem);' --allow-root

# 返回 WP_Filesystem_Direct → 正常
# 返回 WP_Filesystem_ftpsockets 或其他 → 已被污染

第二步——定位污染源:逐个禁用插件,检查哪个插件替换了适配器:

docker exec wp_cli wp plugin deactivate <plugin-name> --allow-root
docker exec wp_cli wp eval 'global $wp_filesystem; echo get_class($wp_filesystem);' --allow-root
# 重复直到返回 WP_Filesystem_Direct

第三步——代码兜底:在主题中加 file_get_contents() 作为 fallback:

function mytheme_get_svg( $path ) {
global $wp_filesystem;

// 优先使用 WP_Filesystem
if ( $wp_filesystem && method_exists( $wp_filesystem, 'get_contents' ) ) {
$content = $wp_filesystem->get_contents( $path );
if ( $content ) {
return $content;
}
}

// WP_Filesystem 失败时回退到直接读取
if ( file_exists( $path ) ) {
return file_get_contents( $path );
}

return '';
}

注意事项

  • file_get_contents() 在某些受限主机可能被 disable_functions 禁用,但 VPS 和 Docker 环境通常可用
  • 根治方案是定位并处理污染源插件,代码兜底只是临时方案
  • 此问题具有隐蔽性——SVG 文件本身完好,直接访问正常,只有在 PHP 中通过 WP_Filesystem 读取时才返回空

场景五:命令行发邮件失败,网页端正常

问题现象

通过 wp eval 在命令行调用 wp_mail() 发送邮件,始终失败。但通过 Web 请求触发的邮件(用户注册、联系表单)发送正常。WP Mail SMTP 等插件已正确配置 SMTP。

根因

SMTP 插件通过 Hook 拦截 wp_mail(),将发信通道从 PHP sendmail 切换到 SMTP 服务。但这些插件的 Hook 注册依赖 WordPress 完整启动流程——特别是 wp_loaded 之后的阶段。

WP-CLI 的 wp eval 虽然加载了 WordPress 核心,但部分插件 Hook 在 CLI 环境下不会被注册。wp_mail() 回退到 PHP sendmail,而大多数服务器没有配置 sendmail,导致发送失败。

解决方案

方法一——Web 请求测试:在主题中临时添加测试路由,通过浏览器触发:

// 临时添加到 functions.php,测试完立即删除
add_action( 'wp_loaded', function() {
if ( ! isset( $_GET['test_mail'] ) ) return;
if ( '1' !== $_GET['test_mail'] ) return;

$result = wp_mail( '[email protected]', 'SMTP Test', 'Test email body' );
var_dump( $result ); // true = 发送成功
exit;
} );

访问 https://yoursite.com/?test_mail=1 触发测试。

方法二——eval-file 确保完整加载

cat > /tmp/test-smtp.php << 'EOF'
<?php
require_once ABSPATH . 'wp-load.php';
do_action('wp_loaded');

$result = wp_mail('[email protected]', 'CLI SMTP Test', 'Test body');
echo $result ? "Sent\n" : "Failed\n";
EOF

docker exec wp_cli wp eval-file /tmp/test-smtp.php --allow-root

最佳实践

生产环境中,邮件发送验证应通过 Web 请求测试。WP-CLI 适合定时任务和批量操作,但不适合验证依赖完整 WordPress Hook 链的功能(如邮件、缓存预热等)。


WooCommerce 升级后编辑器显示异常、页面 404?Block Theme 排查指南

· 阅读需 7 分钟

在为客户构建 WooCommerce Block Theme 时遇到了这四个与 WooCommerce 相关的问题,每个都与 FSE 架构和 WooCommerce 的块系统有关。记录排查过程,帮助同样在做 WooCommerce 主题开发的同学避坑。

TL;DR

四个 WooCommerce Block Theme 开发中的常见问题:块重命名导致 core/missing(升级后块名加了 -block 后缀)、修改 shop slug 后产品归档 404(rewrite 缓存未刷新)、模板 HTML 与 Gutenberg save 不匹配(动态块验证失败)、Cart/Checkout 模板未自动分配(需要手动指定)。每个场景给出可直接复用的修复方案。

场景一:WooCommerce 升级后编辑器出现 core/missing

问题现象

升级 WooCommerce 后,Site Editor 中多个块显示为 core/missing(Block Recovery 提示)。前端渲染看起来正常,但编辑器中无法编辑这些块。

根因

WooCommerce 在新版本中将多个内部块重命名,统一添加了 -block 后缀。例如:

旧名称新名称
cart-order-summary-subtotalcart-order-summary-subtotal-block
cart-order-summary-shippingcart-order-summary-shipping-block
cart-order-summary-taxescart-order-summary-taxes-block
cart-order-summary-totaltotals-block
proceed-to-checkoutproceed-to-checkout-block

如果你的模板或 Pattern 中使用了旧名称的块注释(如 <!-- wp:woocommerce/cart-order-summary-subtotal -->),Gutenberg 找不到对应的块注册,将其渲染为 core/missing

前端渲染正常是因为 WooCommerce 的 PHP 回调函数仍然兼容旧名称,但编辑器依赖块的 JavaScript 注册信息,找不到就显示为 missing。

解决方案

诊断:在浏览器控制台确认已注册的块名称:

wp.blocks.getBlockTypes()
.map(b => b.name)
.filter(n => n.includes('cart-order-summary'))

对比输出与模板中使用的名称,找出不一致的。

修复:批量替换模板和 Pattern 文件中的旧块名为新名称:

# 在主题目录下执行
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-subtotal/woocommerce\/cart-order-summary-subtotal-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-shipping/woocommerce\/cart-order-summary-shipping-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-taxes/woocommerce\/cart-order-summary-taxes-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-total/woocommerce\/totals-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/proceed-to-checkout/woocommerce\/proceed-to-checkout-block/g' {} +

替换后清理数据库中的模板缓存(见上一篇"修改不生效"场景),确保新名称生效。

注意事项

  • 升级 WooCommerce 前备份模板文件,块重命名通常出现在大版本升级中
  • 前端渲染正常不代表没有问题,一定要在 Site Editor 中检查
  • 订阅 WooCommerce 开发者 changelog,提前了解块名称变更

场景二:改了商店网址后,产品列表页打不开了

问题现象

修改了 WooCommerce 商店页面的 slug(如从 /shop/ 改为 /products/),或修改了固定链接结构后,访问新的商店 URL 返回 404 或显示为普通页面(没有产品列表)。

根因

WordPress 的 Rewrite API 会在首次访问时将 URL 路由规则缓存到数据库中。修改页面 slug 后,缓存中的路由映射仍然指向旧 slug。新的 URL 没有对应的路由规则,WordPress 将其渲染为普通页面而非 WooCommerce 产品归档。

解决方案

修改 slug 或固定链接结构后,必须刷新 rewrite 缓存:

# 本地 Docker 环境
docker exec wp_cli wp rewrite flush --allow-root

# 服务器环境
ssh yougu-wp "docker exec prod_cli wp rewrite flush --allow-root"

一条命令即可解决。也可以在 WordPress 后台手动操作:设置 → 固定链接 → 保存更改,保存时会自动刷新 rewrite 规则。

最佳实践

任何涉及 URL 结构变更的操作(修改 slug、变更固定链接、新增自定义文章类型)之后,都应该执行 wp rewrite flush。建议在部署脚本中加入此命令,避免遗忘。

场景三:Cart/Checkout 块报验证错误

问题现象

WooCommerce 的 Cart、Checkout 等动态块在 Site Editor 中显示 Block validation failed。错误信息提示 Expected HTML 和 Actual HTML 不一致。

根因

WooCommerce 的许多块是服务端渲染(SSR)的动态块——前端内容由 PHP 实时生成,不依赖 JavaScript 的 save() 函数。但 Gutenberg 仍然要求模板 HTML 中的块标记与 save() 输出完全匹配。

如果模板中写的 HTML 与 Gutenberg 期望的输出不一致(多了或少了 class、style、内部元素),就会触发验证错误。

WooCommerce 常见块的 save 输出对照:

save 输出
product-price<div class="is-loading"></div>
price-filter<div class="wp-block-woocommerce-price-filter is-loading"><span aria-hidden="true" class="wc-block-product-categories__placeholder"></span></div>
woocommerce/cart<div class="wp-block-woocommerce-cart alignwide is-loading"></div>
woocommerce/checkout<div class="wp-block-woocommerce-checkout alignwide wc-block-checkout is-loading"></div>
cart-order-summary-*-block<div class="wp-block-woocommerce-{block-name}"></div>
proceed-to-checkout-block<div class="wp-block-woocommerce-proceed-to-checkout-block"></div>

解决方案

关键规则

  1. html: false 的块(save 返回 null,如 core/query-pagination)可以使用自闭合标签
  2. JS save 返回 placeholder HTML 的块禁止自闭合,必须包含完整内容
  3. core/query-pagination 禁止写 wrapper div,InnerBlocks 直接放在 open/close 注释之间

排查流程

  1. 打开 Site Editor,打开浏览器 DevTools Console
  2. 找到 Block validation failed 日志
  3. 对比 Expected(Gutenberg 生成)和 Actual(你的模板中写的)
  4. 按上方表格修复 HTML

可忽略的验证错误

以下 WooCommerce 验证错误可以安全忽略:

错误说明
woocommerce/cartwoocommerce/checkout 验证失败动态块,PHP 实时渲染,前端显示正常
woocommerce-blocktheme-css 加载错误WooCommerce 插件 CSS 问题,等待官方修复

场景四:自定义购物车/结算模板没生效

问题现象

创建了自定义的 Cart 或 Checkout 模板文件(如 templates/cart.htmltemplates/checkout.html),但对应的 WooCommerce 页面仍然使用默认模板。在 Site Editor 中能看到自定义模板,但它没有自动应用到 Cart/Checkout 页面。

根因

WooCommerce 的 Cart 和 Checkout 页面会自动匹配对应名称的模板。但如果匹配失败(如模板 slug 不完全对应,或页面创建顺序问题),就需要手动分配。

My Account 等依赖 shortcode 的页面不会自动匹配模板,必须手动分配。

解决方案

手动将模板分配给对应页面:

# 1. 查找 Cart 页面 ID
docker exec wp_cli wp post list --post_type=page --fields=ID,post_title,post_name --allow-root | grep -i cart

# 2. 分配模板
docker exec wp_cli wp post meta update <page_id> _wp_page_template <template_slug> --allow-root

# 示例:将 cart-block 模板分配给 Cart 页面
docker exec wp_cli wp post meta update 42 _wp_page_template cart-block --allow-root

验证分配结果:

docker exec wp_cli wp post meta get <page_id> _wp_page_template --allow-root

模板命名建议

确保模板文件名与 WooCommerce 页面功能对应。标准命名:

  • cart.htmlcart-block.html → Cart 页面
  • checkout.htmlcheckout-block.html → Checkout 页面
  • single-product.html → 产品详情页
  • archive-product.html → 产品归档页(shop)

保持命名一致可以提高自动匹配的成功率。


SITE123 活动页面嵌入时间轴?先绕过这 5 个平台限制

· 阅读需 5 分钟

TL;DR

在 SITE123 平台的活动页面嵌入时间轴,踩了 5 类坑:Custom Code 无法精准定位、脚本在 DOM 未加载时执行、选择器命中 隐藏元素float 布局冲突导致相邻区块错位、平台缓存造成 脚本重复注入。解决方案:JS 动态 DOM 操作 + 横向鱼骨布局 + DOMContentLoaded 包裹。


问题现象

客户基于 SITE123 搭建活动网站,需要在 Hero 横幅下方嵌入竖排时间轴,展示关键日期(Key Dates)。Timeline 服务已自建完成(部署在 timeline.aidevhub.ai),但嵌入到 SITE123 页面后:

  • 时间轴不显示
  • 时间轴出现在页面底部
  • 时间轴插入后,Tickets 区域被挤到下方

根因

限制一:Custom Code 无法精准定位

SITE123 的 Custom Code 只能选择 4 个注入位置(head 开头、head 结尾、body 开头、body 结尾),无法指定目标页面,也无法插入到页面某个 section 之间

这是最根本的限制,绕不过去。只能通过 JS 动态操作 DOM,在页面加载后自行找到目标节点再插入。

限制二:脚本在 DOM 未加载时执行

如果将代码放在 "Before closing head tag",document.body 在执行时为 null。更诡异的是,在 SITE123 后台修改注入位置后,页面源码中的实际位置可能与设置不符。

解决方案:无论脚本放在哪,都用 DOMContentLoaded 包裹,确保 DOM 就绪后再执行。

限制三:选择器命中隐藏元素

tl.js 的插入逻辑优先查找 .container,但 SITE123 活动页面的 .container 是面包屑导航区域,设有 visibility: hidden,时间轴被插入到隐藏元素前,页面不可见。

querySelector 只取第一个匹配项,而 .container 是通用类名,很容易踩坑。

教训:选择器必须通过浏览器控制台在实际页面验证,不能依赖推断。

限制四:float 布局冲突

SITE123 活动页面采用 Bootstrap float 布局(col-sm-5 + col-sm-7,12 列刚占满)。在 .product-container 之前插入独占一行的元素,会导致 Tickets 列被挤到下一行。

这是结构性冲突——在 float 容器内部无法插入独占行元素而不破坏布局。

解决方案:改用横向鱼骨布局(贯穿线 + 圆点 + 上下交替卡片),width:100% 自然撑满,不参与 float 计算。

限制五:平台缓存导致脚本重复注入

修改 Custom Code 位置设置后,页面源码中出现两处 timeline.aidevhub.ai——同一条代码被注入了两次。这是 SITE123 的平台 bug,暂未找到根因,但不影响最终效果。


解决方案

1. 脚本执行时机兜底

DOMContentLoaded 包裹全部插入逻辑,不再依赖 SITE123 的位置设置:

document.addEventListener('DOMContentLoaded', function() {
// 插入逻辑
});

2. 选择器必须实测

在浏览器控制台验证三步走:

// 第一步:确认元素存在且可见
const el = document.querySelector('.product-container');
console.log(el.getBoundingClientRect());

// 第二步:确认唯一性(跨页面验证返回值为 1)
const all = document.querySelectorAll('.product-container');
console.log(all.length);

// 第三步:插入测试色块确认位置
const test = document.createElement('div');
test.style.cssText = 'background:red;height:100px;';
el.parentNode.insertBefore(test, el);

3. 布局选型

采用横向鱼骨布局:一条贯穿横线 + 圆点定位在线上 + 卡片上下交替。竖排布局在 float 场景下容易引发结构冲突,横向布局天然不参与 float 计算。

4. 插入位置

最终验证有效的插入目标:.product-container(活动详情和 Tickets 的父容器),在其之前插入时间轴,位置回到 Hero 下方、活动详情之前。

5. 部署生效

修改 routes/script.js 后执行 pm2 restart timeline 即生效,无需更新 SITE123 的嵌入代码


注意事项

选择器必须实测

SITE123 页面结构没有文档,选择器不能靠推断。.container 在其他场景是通用类名,在活动页面匹配到的是面包屑导航。始终用浏览器控制台在实际页面验证。

Custom Code 位置设置不可靠

在 SITE123 后台修改注入位置后,页面源码中的实际位置可能与设置不符。脚本自身做好执行时机的兜底,不要依赖平台设置。

float 布局内不能插入独占行元素

在 Bootstrap float 布局(col-*)的容器内部或之前插入 width:100% 的块级元素,会导致后续列被挤到下一行。绕行方案:插入位置改到容器外部,或改用不依赖 float 的布局。


自建 Timeline 服务,服务器怎么选?

本项目使用 Express + sql.js 部署在 Vultr,最低 $5/月配置足够,支持 PM2 进程管理。附安装脚本,5 分钟搭建完毕。

Vultr $5/月起 →

关键结论

  1. SITE123 Custom Code 无法精准定位,必须靠 JS 动态 DOM 操作弥补
  2. 脚本自身做好 DOMContentLoaded 兜底,不依赖平台位置设置
  3. 选择器用控制台在实际页面验证,querySelectorAll + getBoundingClientRect
  4. float 布局内插入独占行元素会引发结构冲突,改用横向布局绕过
  5. 修改服务端代码后 pm2 restart timeline 即生效,无需动嵌入代码

Docker 容器读不到宿主机文件?匿名 Volume 覆盖 Bind Mount

· 阅读需 3 分钟

在为客户部署 WordPress 站点时遇到此问题,记录根因与解法。

TL;DR

镜像 Dockerfile 中的 VOLUME 声明会创建匿名卷,挂载优先级高于 docker-compose.yml 的 bind mount。解决方案:停止容器 → 删除匿名卷 → 重启。

问题现象

CI 部署后,新增的静态文件或 PHP 代码在容器内不存在:

  • assets/images/logo.png 宿主机存在,容器内无 → Logo 不显示
  • inc/setup.php 新增 filter 代码,容器内是旧版本 → Filter 不生效
  • git pull 后文件已更新,容器内仍是旧内容

根因

WordPress 官方镜像的 Dockerfile 包含:

VOLUME /var/www/html

即使你的 docker-compose.yml 配置了 bind mount:

volumes:
- ./wordpress/wp-content:/var/www/html/wp-content

Docker 仍会为 VOLUME 声明的路径创建匿名卷,匿名卷的挂载优先级高于 bind mount,导致:

  1. /var/www/html 被匿名卷接管
  2. 你的 bind mount 只挂载到 /var/www/html/wp-content
  3. 但匿名卷已经"占领"了父目录,bind mount 实际上被覆盖

docker inspect 验证:

docker inspect prod_wordpress --format '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

输出类似:

volume: /var/lib/docker/volumes/wp-prod_wp_html/_data -> /var/www/html# 匿名卷!
bind:/var/www/wp-prod/wordpress/wp-content -> /var/www/html/wp-content

解决方案

# 1. 停止容器
cd /var/www/wp-prod && docker compose down

# 2. 删除匿名卷
docker volume rm wp-prod_wp_html

# 3. 重启
docker compose up -d

验证

docker inspect prod_wordpress --format '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

应只显示 bind mount,无匿名 volume:

bind: /var/www/wp-prod/wordpress/wp-content -> /var/www/html/wp-content

预防

使用 bind mount 部署时,检查镜像是否声明了 VOLUME。如果声明了:

  1. 首次启动前,确认没有残留的匿名卷
  2. 或者修改 docker-compose.yml,让 bind mount 路径与 VOLUME 路径一致(挂载到同一层级)

修复后,git pull 更新代码时容器自动读取新文件,无需 docker cp 或重启容器。

注意事项

  • 删除匿名卷前确保数据已备份或可通过git pull 恢复
  • 生产环境操作前先在测试环境验证
  • 如果容器内有关键数据,先用 docker cp 备份

停用插件后主题 JS 失效?检查 Query Monitor 的 footer scripts 回调

· 阅读需 4 分钟

在为客户开发 WordPress 主题时遇到此问题,记录根因与解法。

TL;DR

WordPress 主题的导航滚动变色功能(.is-scrolled 类)在停用 WooCommerce 插件后失效。排查发现是 Query Monitor 插件的 action_print_footer_scripts 回调优先级 9999 太低,提前执行并终止了所有 footer scripts 输出。删除 Query Monitor 后问题解决。

问题现象

WordPress 主题实现了导航滚动变色功能:用户向下滚动页面时,header 添加 .is-scrolled 类,触发毛玻璃效果和阴影。

测试时发现:

  • WooCommerce 激活时:滚动变色功能正常,页面大小 144KB
  • WooCommerce 停用时:滚动变色失效,页面大小仅 94KB

通过 Playwright 测试确认:

  • WooCommerce 激活时:theme.js 正常加载,.is-scrolled 类正常添加/移除
  • WooCommerce 停用时:theme.js 未加载。页面 HTML 在 </footer> 后直接结束,缺少 </body></html>,所有 footer scripts 都没有输出

根因

排查过程

  1. 检查主题 JS 入队条件inc/setup.php 第 44-50 行的 wp_enqueue_script 是无条件执行的,没有 WooCommerce 依赖

  2. 检查页面脚本输出:使用 curl 获取页面 HTML,发现 WooCommerce 停用时页面只有 2 个 script 标签(importmap, speculationrules),没有任何主题或 WordPress 核心脚本

  3. 检查页面完整性:页面 HTML 在 footer 后直接结束,缺少 </body></html> 标签

  4. 搜索 wp_print_footer_scripts 回调:在 query-monitor/collectors/assets.php:55 发现:

add_action( 'wp_print_footer_scripts', array( $this, 'action_print_footer_scripts' ), 9999 );

关键发现

优先级 9999 极低:在 WordPress hook 系统中,数字越大优先级越低。这个回调会在所有正常脚本输出之后执行,但其内部逻辑可能提前终止了输出流程。

无配置文件时仍激活:Query Monitor 在没有 query-monitor.php 配置文件时仍会注册这个回调。

回调行为action_print_footer_scripts 方法输出空内容并终止了后续所有脚本输出。

影响范围

任何依赖 wp_print_footer_scriptswp_footer hook 输出的脚本都会受影响,包括:

  • 主题 JavaScript(theme.js
  • WordPress 核心 scripts
  • 其他插件的 footer scripts

解决方案

方案一:删除 Query Monitor 插件(推荐)

# 在容器内删除
docker exec -it wordpress_container rm -rf /var/www/html/wp-content/plugins/query-monitor

# 或使用 WP-CLI
wp plugin delete query-monitor

方案二:修改回调优先级

如果需要保留 Query Monitor,修改 query-monitor/collectors/assets.php:55

// 原代码(优先级 9999 - 太低)
add_action( 'wp_print_footer_scripts', array( $this, 'action_print_footer_scripts' ), 9999 );

// 修改为(优先级 999 - 正常范围)
add_action( 'wp_print_footer_scripts', array( $this, 'action_print_footer_scripts' ), 999 );

验证修复

# 重新测试页面大小
curl -s http://localhost:8080 | wc -c
# 应该恢复到 144KB 左右

# 检查 theme.js 是否加载
curl -s http://localhost:8080 | grep -o "theme.js"
# 应该能看到 theme.js 的 script 标签

注意事项

排查过程中发现,Query Monitor 在某些配置下会干扰正常的 WordPress 脚本输出流程。建议仅在开发环境使用该插件,生产环境应禁用或删除。

排查技巧总结

  1. 页面大小对比:正常页面与异常页面大小差异明显(144KB vs 94KB)
  2. HTML 完整性检查:检查是否有 </body></html> 结束标签
  3. 脚本加载检查:使用 Playwright 或 curl 检查特定 JS 文件是否加载
  4. Hook 优先级分析:搜索 wp_print_footer_scripts9999 等低优先级回调
  5. 插件隔离:逐个停用插件排查冲突源

修复 WSL2 无法访问 Windows 宿主机代理 — 三个隐形坑

· 阅读需 6 分钟

在 WSL2 环境下使用 Claude Code、Roo 等 AI 编程工具时,工具需要通过 Windows 宿主机代理访问 API,却反复报 ConnectionRefused。解决防火墙问题后,又遇到两个隐藏坑。记录完整排查过程。

TL;DR

WSL2 的 vEthernet (WSL) 虚拟网卡每次启动重建,Windows 防火墙无法为其分配 Network Profile,导致所有入站规则对这个接口都不生效EnforcementStatus: NotApplicable)。不需要加规则,直接对接口禁用防火墙即可:

# 在 Windows PowerShell (管理员) 中执行
Set-NetFirewallProfile -DisabledInterfaceAliases "vEthernet (WSL)"

此外,修复防火墙后,还可能遇到两个额外坑

  1. 动态 IP 问题:宿主机 IP 在 WSL/Windows 重启后变化
  2. 配置缓存问题~/.claude.json~/.claude/settings.json 里缓存了旧 API key 导致认证冲突

问题现象

环境:Windows 10 21H2 + WSL2 2.5.10.0(NAT 模式),宿主机 Clash Verge(端口 7897,Allow LAN 已开启)。

症状:

# 从 WSL ping 宿主机 — 无响应
ping 172.22.80.1

# 测试代理端口 — 无响应
nc -zv 172.22.80.1 7897

# Claude Code / Roo 报错
# API Error: ConnectionRefused

# 但直接访问外网正常
curl https://example.com # OK

Windows 侧 Clash 健康运行: netstat 显示 0.0.0.0:7897 监听中,Test-NetConnection localhost 7897 成功。

根因一:防火墙隐形拦截

vEthernet (WSL) 是 WSL2 每次启动时动态创建的虚拟网卡。 Windows 防火墙的 Network Profile(域/专用/公用)无法关联到这个接口。

这意味着:

  1. 鯿火墙入站规则绑定到 Profile
  2. vEthernet(WSL) 不属于任何 Profile
  3. 所有入站规则对这个接口显示 EnforcementStatus: NotApplicable
  4. 规则被直接跳过 — 不是拒绝,是根本不评估

无论加多少条端口规则或 InterfaceAlias 绑定都没用,因为规则根本不会对这个接口执行。

解决方案一:一条命令修复防火墙

第一步:确认根因

在 Windows PowerShell(管理员)中:

# 查看虚拟网卡接口
Get-NetAdapter | Where-Object { $_.Name -like "*WSL*" }

# 查看入站规则的 EnforcementStatus
Get-NetFirewallRule -DisplayName "*7897*" |
Get-NetFirewallInterfaceFilter |
Get-NetFirewallPortFilter |
Format-Table -Property * -AutoSize

如果规则存在但 WSL 仍不通,说明 Profile enforcement 是问题所在。

第二步:一条命令修复

# 直接对 vEthernet(WSL) 禁用防火墙
Set-NetFirewallProfile -DisabledInterfaceAliases "vEthernet (WSL)"

这条命令的效果:

  • 仅对 vEthernet (WSL) 这个虚拟网卡接口生效
  • 物理网卡(Wi-Fi、以太网)的防火墙规则完全不受影响
  • 设置持久化,重启后保留

第三步:验证

# WSL 内测试连通性
nc -zv 172.22.80.1 7897
# 输出: Connection to 172.22.80.1 7897 port [tcp/*] succeeded!

# 测试代理
curl -x http://172.22.80.1:7897 https://api.anthropic.com

根因二:动态 IP 变化

修复防火墙后,代理可能仍然失败。为什么?vEthernet (WSL) 的 IP 每次启动都会变。

如果在 .bashrc 里硬编码了 IP:

export http_proxy=http://172.22.80.1:7897  # 这个 IP 可能变了!

修复:在 .bashrc 中动态获取宿主机 IP:

# 从 DNS 服务器自动获取宿主机 IP
export WINDOWS_IP=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}')
export http_proxy="http://${WINDOWS_IP}:7897"
export https_proxy="http://${WINDOWS_IP}:7897"

根因三:配置文件残留缓存

修复防火墙和动态 IP 后,Claude Code/GLM 可能仍报认证冲突。为什么?缓存的 key 存在于:

  1. ~/.bashrc 顶部全局 export ANTHROPIC_API_KEY=sk-ant-...
  2. ~/.claude.jsoncustomApiKeyResponses.approved 存了之前用过的 Anthropic key
  3. ~/.claude/settings.jsonenv 字段可能有硬编码的环境变量

切换到 GLM 模式时,unset ANTHROPIC_API_KEY 临时有效,但 bashrc 重新加载后又 export 了。Claude Code 读取这三个来源,发现冲突。

解决方案三:清理缓存 Key

# 1. 检查是否冲突
echo $ANTHROPIC_API_KEY $ANTHROPIC_AUTH_TOKEN

# 2. 清理 ~/.claude.json 缓存
cat ~/.claude.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('customApiKeyResponses', {}))"

# 3. 检查 ~/.claude/settings.json
cat ~/.claude/settings.json | grep -A2 '"env"'

修复:删除 settings.json 里的硬编码值:

python3 -c "
import json
with open('/home/aptop/.claude/settings.json') as f:
d = json.load(f)
d['env'].pop('ANTHROPIC_API_KEY', None)
d['env'].pop('HTTPS_PROXY', None)
d['env'].pop('HTTP_PROXY', None)
with open('/home/aptop/.claude/settings.json', 'w') as f:
json.dump(d, f, indent=2)
"

对于 ~/.claude.json,手动编辑文件,从 customApiKeyResponses.approved 数组中删除过时的 key。

注意事项

  • 代理端口
    • 如果你的 Clash 端口不是 7897,替换成实际端口即可,关键是修复防火墙接口而非端口
    • WSL 更新时 Windows 大版本更新可能重置虚拟网卡配置,复发时重新执行同一条命令
    • Windows 10 不支持 WSL2 的 mirrored 网络模式(仅 Windows 11 23H2+),不要尝试这个方向
  • 动态 IP
    • 始终在 .bashrc 中使用动态获取 IP,不要硬编码宿主机 IP
    • /etc/resolv.conf 里的 DNS 服务器 IP 在 WSL 重启后是稳定的
  • 配置缓存
    • 清理缓存 key 后需重启 Claude Code 才能生效
    • 如果认证仍然失败,按顺序检查三个来源:环境变量、~/.claude.json~/.claude/settings.json

排查顺序(下次参考)

  1. echo $ANTHROPIC_API_KEY $ANTHROPIC_AUTH_TOKEN — 确认是否冲突
  2. cat ~/.claude.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('customApiKeyResponses', {}))" — 确认缓存 key
  3. cat ~/.claude/settings.json | grep -A2 '"env"' — 确认 settings 里有无硬编码
  4. curl https://open.bigmodel.cn — 确认 GLM 可达

死路:5 个已排除的方向

#方向结果原因
1代理环境变量排除.bashrc unset/export 模式是设计用于模型切换
2Clash Allow LAN 未开启排除Allow LAN 已开启,监听 0.0.0.0:7897,localhost 测试通过
3防火墙端口规则排除添加端口 7897 入站规则后仍不通 — NotApplicable
4绑定规则到 InterfaceAlias排除Set-NetFirewallRule -InterfaceAlias "vEthernet (WSL)" 无效
5Mirrored 网络模式排除Windows 10 不支持

核心洞察:我们一直尝试添加规则,但问题不是缺少规则 — 而是规则对这个接口根本不生效。