# 07 · 性能、安全与部署

> 适用：涉及上游接口、密钥、限流、第三方 HTML 渲染、样式产物、打包与上线时。
> 相关分册：目录结构与 `inc/` 分层见 `01-技术栈与项目结构.md`；样式产物与构建见 `02-样式与构建.md`；SEO 上线项见 `06-SEO与Yoast集成.md`。
> 参考实现：`inc/config.php`、`inc/class-mailcx-client.php`、`inc/rest-api.php`、`inc/friend-links.php`、`functions.php`。

一句话原则：**密钥只存在服务端、第三方内容一律过滤后再渲染、所有可变配置集中在一个文件、上线前逐条过检查清单。**

> **命名口径**：本文出现的 `XY_MBX_*` 前缀是**参考实现**的真实命名（用于对照源码）；标为「可直接复制」的代码块里使用 `mytheme` / `mytheme/v1` 等**骨架占位前缀**。复制到新项目时，请把两者统一替换为你自己的主题短名（例如全项目统一为 `mytheme`）。

---

## 一、密钥与集中配置

### 1.1 集中配置层骨架（`inc/config.php` 全文，可直接复制）

设计意图：主题所有可变配置（接口地址、凭证、缓存 / 限流 / 超时参数）只在此文件定义，其它文件一律通过常量引用，不得硬编码。

**每个常量都必须用 `if ( ! defined( 'X' ) )` 保护**——这样才可以在 `wp-config.php` 里提前定义同名常量覆盖主题默认值（见 1.2）。

```php
<?php
/**
 * 主题集中配置层
 *
 * 本示例为通用骨架，请勿把真实凭证写进本文档；生产凭证建议写在 wp-config.php，本文件只保留占位值。
 *
 * 设计意图：本主题所有可变配置（接口地址、凭证、缓存/限流/超时参数）只在此文件定义，
 * 其它文件一律通过常量引用，不得硬编码，方便更换环境时只改这一处。
 *
 * 更换方法：直接修改下方 define() 的第二个参数即可（每个常量都用 defined() 保护，
 * 也可以在 wp-config.php 中提前定义同名常量来覆盖主题默认值）。
 *
 * @package mytheme
 */

// 防止直接访问该文件。
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * 小影 API 服务基址（mailcx 虚拟邮箱，仅收信）。
 * 更换环境时修改此项，注意结尾不要带斜杠。
 */
if ( ! defined( 'XY_MBX_API_BASE' ) ) {
	define( 'XY_MBX_API_BASE', 'https://api.example.com' );
}

/**
 * 小影 API 应用 ID。
 * 注意：仅服务端使用，严禁下发前端。
 */
if ( ! defined( 'XY_MBX_APP_ID' ) ) {
	define( 'XY_MBX_APP_ID', 'YOUR_APP_ID' );
}

/**
 * 小影 API 应用密钥，用于 HMAC-SHA256 签名。
 * 注意：仅服务端使用，严禁下发前端（不得出现在任何 JS 输出中）。
 */
if ( ! defined( 'XY_MBX_APP_SECRET' ) ) {
	define( 'XY_MBX_APP_SECRET', 'YOUR_APP_SECRET' );
}

/**
 * 内置兜底域名列表。
 * 当域名接口不可用（网络异常、上游报错）时使用，保证前端流程不中断。
 */
if ( ! defined( 'XY_MBX_FALLBACK_DOMAINS' ) ) {
	define( 'XY_MBX_FALLBACK_DOMAINS', array( 'example.com', 'example.org' ) );
}

/**
 * 域名列表缓存时长（秒）。缓存键：xy_mbx_domains。
 */
if ( ! defined( 'XY_MBX_DOMAINS_CACHE_TTL' ) ) {
	define( 'XY_MBX_DOMAINS_CACHE_TTL', 600 );
}

/**
 * 限流窗口（秒）。
 */
if ( ! defined( 'XY_MBX_RATE_WINDOW' ) ) {
	define( 'XY_MBX_RATE_WINDOW', 60 );
}

/**
 * 限流规则：窗口内每 IP、每类接口允许的最大请求次数。
 * 键名与 REST 路由对应：mailbox=生成邮箱、emails=邮件列表、email=邮件详情。
 */
if ( ! defined( 'XY_MBX_RATE_RULES' ) ) {
	define(
		'XY_MBX_RATE_RULES',
		array(
			'mailbox' => 5,
			'emails'  => 60,
			'email'   => 30,
		)
	);
}

/**
 * 真实访客 IP 的来源请求头（站点位于 CDN / 反向代理之后时必须配置）。
 *
 * 默认空字符串：限流直接使用 REMOTE_ADDR。
 * 站点接入 Cloudflare 时请设为 'HTTP_CF_CONNECTING_IP'；用 Nginx 等反代时可设为 'HTTP_X_FORWARDED_FOR'。
 * 不配置的后果：REMOTE_ADDR 会是 CDN 节点 IP，于是所有访客共用一个限流桶
 * （例如整站每分钟只能生成 5 个邮箱），真实流量一上来就会被 429 拦住。
 * 注意：只有当源站仅接受该代理回源时该请求头才可信；若源站可被直连，该头理论上可被伪造（仅影响限流，非安全边界）。
 */
if ( ! defined( 'XY_MBX_PROXY_IP_HEADER' ) ) {
	define( 'XY_MBX_PROXY_IP_HEADER', '' );
}

/**
 * 默认请求超时（秒），用于域名、生成邮箱、邮件详情等短耗时接口。
 */
if ( ! defined( 'XY_MBX_TIMEOUT_DEFAULT' ) ) {
	define( 'XY_MBX_TIMEOUT_DEFAULT', 20 );
}

/**
 * 长轮询请求超时（秒），用于邮件列表接口。
 * 该接口无新邮件时服务端会挂起约 25 秒，因此超时必须大于该值，否则会误判为失败。
 */
if ( ! defined( 'XY_MBX_TIMEOUT_LONG_POLL' ) ) {
	define( 'XY_MBX_TIMEOUT_LONG_POLL', 40 );
}

/**
 * 邮件详情服务端缓存时长（秒）。缓存键：xy_mbx_mail_ + md5(邮件ID)。
 * 邮件内容不可变，缓存可显著降低打开耗时与上游调用。
 */
if ( ! defined( 'XY_MBX_MAIL_DETAIL_CACHE_TTL' ) ) {
	define( 'XY_MBX_MAIL_DETAIL_CACHE_TTL', 600 );
}

/**
 * 友情链接缓存时长（秒）。缓存键：xy_mbx_friend_links。
 * 页脚友链为服务端渲染，正常情况每小时最多向上游取一次，把上游耗时挡在页面渲染之外。
 */
if ( ! defined( 'XY_MBX_FRIEND_LINKS_CACHE_TTL' ) ) {
	define( 'XY_MBX_FRIEND_LINKS_CACHE_TTL', 3600 );
}

/**
 * 友情链接上游失败时的短期空缓存时长（秒）。
 * 上游异常时写一个空数组的短期缓存，避免每次访问都去打上游导致页面持续变慢。
 */
if ( ! defined( 'XY_MBX_FRIEND_LINKS_FAIL_TTL' ) ) {
	define( 'XY_MBX_FRIEND_LINKS_FAIL_TTL', 300 );
}
```

### 1.2 生产环境推荐：在 `wp-config.php` 覆盖（凭证不进主题目录）

写在 `wp-config.php` 的 `/* That's all, stop editing! */` 注释**之前**：

```php
/* 主题配置：wp-config.php 中先定义即可覆盖 inc/config.php 的默认值（主题升级不会丢） */
define( 'XY_MBX_API_BASE', 'https://你的API域名' );        // 结尾不要带 /
define( 'XY_MBX_APP_ID', 'app_你的APPID' );
define( 'XY_MBX_APP_SECRET', 'sk_你的APPSECRET' );
define( 'XY_MBX_PROXY_IP_HEADER', 'HTTP_CF_CONNECTING_IP' ); // 站点在 CDN 之后时必须配
```

为什么推荐：① 密钥不落在主题目录里，主题升级 / 换主题不会覆盖；② 同一份主题代码可以在多套环境复用，只改 `wp-config.php`；③ 主题目录即使被误打包分发，也不含生产密钥。

### 1.3 常量总表（名称 / 默认值 / 含义 / 何时必须改）

> 本表列的是参考实现的 13 个常量；`templates/inc/config.php` 骨架把它泛化为 9 个（把两个缓存 TTL 合并为 `MYTHEME_CACHE_TTL`、`TIMEOUT_DEFAULT` 改名为 `MYTHEME_TIMEOUT`，并省略业务专用常量），以骨架为准。

| 常量 | 默认值 | 含义 | 何时必须改 |
| --- | --- | --- | --- |
| `XY_MBX_API_BASE` | `https://api.example.com` | 上游 API 基址（**不带结尾斜杠**） | **每次换环境都改**；生产必须是 https 域名 |
| `XY_MBX_APP_ID` | 开发用 APPID | 应用标识，参与签名 | 生产必须换成生产凭证 |
| `XY_MBX_APP_SECRET` | 开发用密钥 | HMAC-SHA256 签名密钥 | 生产必须换；**仅服务端使用，绝不下发前端** |
| `XY_MBX_FALLBACK_DOMAINS` | `example.com / example.org` | 域名接口异常时的兜底域名 | 上游不可用时要保证前端仍能生成邮箱；凭证方给的兜底域名变化时改 |
| `XY_MBX_DOMAINS_CACHE_TTL` | `600` | 域名列表缓存秒数 | 上游域名变动频繁时可调小 |
| `XY_MBX_RATE_WINDOW` | `60` | 限流窗口（秒） | 与 `XY_MBX_RATE_RULES` 一起按流量调整 |
| `XY_MBX_RATE_RULES` | `mailbox 5 / emails 60 / email 30` | 窗口内每 IP 每类接口的最大请求数 | 真实流量达标后按需放宽（键名必须与限流 `$bucket` 一致） |
| `XY_MBX_PROXY_IP_HEADER` | 空字符串 | 真实访客 IP 的来源请求头 | **站点在 CDN / 反代之后时必须改**：Cloudflare = `HTTP_CF_CONNECTING_IP`，通用反代 = `HTTP_X_FORWARDED_FOR` |
| `XY_MBX_TIMEOUT_DEFAULT` | `20` | 普通接口超时（秒） | 上游慢时可调大 |
| `XY_MBX_TIMEOUT_LONG_POLL` | `40` | 长轮询超时（秒） | 必须大于上游挂起时长（约 25–26 秒）；上游挂起时间变了要跟着改 |
| `XY_MBX_MAIL_DETAIL_CACHE_TTL` | `600` | 邮件详情缓存秒数 | 邮件内容不可变，一般不用改 |
| `XY_MBX_FRIEND_LINKS_CACHE_TTL` | `3600` | 友链缓存秒数 | 友链更新要更快生效时调小 |
| `XY_MBX_FRIEND_LINKS_FAIL_TTL` | `300` | 友链失败后的空缓存秒数 | 上游持续异常时用于止血，一般不用改 |

> 另有四个不在 `config.php` 的常量，同样都用 `defined()` 保护：`XY_MBX_THEME_DIR` / `XY_MBX_THEME_URI`（`functions.php` 顶部定义，主题绝对路径与 URL）；`XY_MBX_FRIEND_LINKS_CACHE_KEY`（友链缓存键，`inc/friend-links.php` 内定义）；`XY_MBX_SEO_KEYWORDS_META`（SEO 关键词字段名，`inc/seo.php` 内定义）。

### 1.4 密钥纪律（违反即等于把接口送人）

1. `XY_MBX_APP_SECRET` **只出现在 PHP 服务端**：不在 JS / HTML / `wp_localize_script` / REST 响应里出现。
2. 前端只允许拿到 REST 基址：

```php
// functions.php：仅注入 REST 基址，app_id / app_secret 等敏感配置绝不下发。
wp_localize_script(
	'xy-mbx-mailbox',
	'XYMailbox',
	array(
		'restUrl' => rest_url( 'mytheme/v1' ),
	)
);
```

3. 交付前自检：`grep -r "APP_SECRET\|app_secret\|sk_"` 只应命中 `inc/config.php` 与 `wp-config.php`（以及文档），**不应命中 `assets/js/`**。
4. 打包分发的 zip 若含凭证，公开渠道分发前必须替换；凭证一旦外流，去上游重置 `app_secret`。
5. 所有密钥常量都用 `defined()` 保护，保证 `wp-config.php` 优先。

---

## 二、对外接口的安全基线

参考实现：`inc/rest-api.php`（REST 转发）+ `inc/class-mailcx-client.php`（上游客户端）。

### 2.1 三条路由与统一响应结构

命名空间 `xiaoying-mailbox/v1`，全部为公开接口（`permission_callback` 返回 `__return_true`），安全靠**参数校验 + IP 限流 + 去重锁**。

```php
/**
 * 统一响应结构。
 *
 * @param int    $code    业务状态码。
 * @param string $message 中文提示。
 * @param mixed  $data    业务数据。
 * @param int    $status  HTTP 状态码。
 * @return WP_REST_Response
 */
function xy_mbx_rest_response( $code, $message, $data = null, $status = 200 ) {
	return new WP_REST_Response(
		array(
			'code' => $code,
			'msg'  => $message,
			'data' => $data,
		),
		$status
	);
}

/**
 * 注册 REST 路由。
 *
 * 全部为公开接口，参数校验与限流在回调内部完成。
 *
 * @return void
 */
function xy_mbx_register_rest_routes() {
	register_rest_route(
		'mytheme/v1',
		'/mailbox',
		array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => 'xy_mbx_handle_mailbox',
			'permission_callback' => '__return_true',
		)
	);

	register_rest_route(
		'mytheme/v1',
		'/emails',
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'xy_mbx_handle_emails',
			'permission_callback' => '__return_true',
		)
	);

	register_rest_route(
		'mytheme/v1',
		'/email',
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'xy_mbx_handle_email_detail',
			'permission_callback' => '__return_true',
		)
	);
}
add_action( 'rest_api_init', 'xy_mbx_register_rest_routes' );
```

| 路由 | 方法 | 用途 | 参数 |
| --- | --- | --- | --- |
| `/wp-json/xiaoying-mailbox/v1/mailbox` | POST | 生成随机域名临时邮箱 | 无 |
| `/wp-json/xiaoying-mailbox/v1/emails` | GET | 邮件摘要列表（长轮询） | `address`（必填）、`since`、`client_id` |
| `/wp-json/xiaoying-mailbox/v1/email` | GET | 邮件详情 | `email_id`（必填）、`client_id` |

**约定**：所有响应恒为 `{code, msg, data}`；`msg` 为中文，可直接展示给终端用户。

### 2.2 参数白名单校验（只接受预期形状的输入）

| 路由 | 参数 | 必填 | 校验规则 | 不通过时返回 |
| --- | --- | --- | --- | --- |
| POST `/mailbox` | — | — | 无参数，仅限流 | `20040` / HTTP 429 |
| GET `/emails` | `address` | ✅ | 非空且 `is_email()` 通过 | `20001` / HTTP 400 |
| GET `/emails` | `since` | ❌ | 空值按缺省处理；否则必须匹配 `^\d+$`（秒级非负整数） | `20002` / HTTP 400 |
| GET `/emails` | `client_id` | ❌ | `trim` 后 `mb_strlen ≤ 64` | `20002` / HTTP 400 |
| GET `/email` | `email_id` | ✅ | 非空；`mb_strlen ≤ 128` | `20001`（空）/ `20002`（超长） |
| GET `/email` | `client_id` | ❌ | 同 `client_id` 规则 | `20002` / HTTP 400 |

校验顺序（**固定动作，不要调换**）：**先取参数并校验 → 再做限流判断 → 最后请求上游**。反过来的话，被限流的请求会先打上游，限流就失去了保护上游的意义。

```php
/**
 * 校验选填的 client_id。
 *
 * @param mixed $client_id 原始参数。
 * @return string|WP_REST_Response 合法时返回处理后的字符串，非法时返回错误响应。
 */
function xy_mbx_validate_client_id( $client_id ) {
	$client_id = trim( (string) $client_id );

	if ( mb_strlen( $client_id ) > 64 ) {
		return xy_mbx_rest_response( 20002, '参数 client_id 长度不能超过 64 个字符', null, 400 );
	}

	return $client_id;
}

/**
 * GET /emails：获取邮件摘要列表。
 *
 * @param WP_REST_Request $request 请求对象。
 * @return WP_REST_Response
 */
function xy_mbx_handle_emails( WP_REST_Request $request ) {
	$address = trim( (string) $request->get_param( 'address' ) );

	if ( '' === $address ) {
		return xy_mbx_rest_response( 20001, '缺少必要参数 address', null, 400 );
	}

	if ( ! is_email( $address ) ) {
		return xy_mbx_rest_response( 20001, '参数 address 不是合法的邮箱地址', null, 400 );
	}

	$since = $request->get_param( 'since' );
	if ( null === $since || '' === $since ) {
		$since = '';
	} elseif ( ! preg_match( '/^\d+$/', (string) $since ) ) {
		// since 必须是秒级非负整数时间戳。
		return xy_mbx_rest_response( 20002, '参数 since 必须是非负整数', null, 400 );
	}

	$client_id = xy_mbx_validate_client_id( $request->get_param( 'client_id' ) );
	if ( $client_id instanceof WP_REST_Response ) {
		return $client_id;
	}

	if ( xy_mbx_is_rate_limited( 'emails' ) ) {
		return xy_mbx_rest_response( 20040, '请求过于频繁，请稍后再试', null, 429 );
	}

	// 后续：抢长轮询锁 → 请求上游 → 释放锁（见 2.5）
	// ...
}
```

**注意事项**

- 校验函数返回 `WP_REST_Response` 表示失败、返回字符串表示成功——调用侧必须用 `instanceof WP_REST_Response` 判定（不要用 `is_wp_error`）。
- 长度校验用 `mb_strlen()`（中文字节数 ≠ 字符数），因此 **mbstring 是主题的必需扩展**。
- `is_email()` 是 WordPress 的地址校验口径，不要自己写正则。
- 参数一律 `trim( (string) ... )` 后再判断，避免空白字符串绕过必填校验。

### 2.3 错误码表

| code | 含义 | HTTP | 触发点 |
| --- | --- | --- | --- |
| `10000` | 成功 | 200 | 三个接口正常返回 |
| `10002` | 已有轮询进行中 | 200 | `/emails` 未抢到该邮箱的长轮询锁（同邮箱已有在途请求，前端延后重试，不报错） |
| `20001` | 缺少必要参数 / 参数格式非法 | 400 | `address` 缺失或非邮箱；`email_id` 缺失 |
| `20002` | 参数非法 | 400 | `since` 非非负整数；`client_id` > 64 字符；`email_id` > 128 字符 |
| `20040` | 请求过于频繁（限流） | 429 | 任一接口超出 `XY_MBX_RATE_RULES` 上限 |
| `40001` | 上游服务调用失败 | 200 | 上游网络失败 / 返回体无法解析 / 上游业务码非 `10000` |

**注意事项**

- `40001` 的 **HTTP 状态是 200**（未显式指定 status），真正的失败信息在 `code` 与 `msg` 里——日志与监控要按 `code` 判断失败，不要只看 HTTP 状态。
- 上游自己的错误码会被拼进 `msg`（例如上游 `20011` = 签名失败），排错时看 `msg` 文本比看 `40001` 更有用。
- 前端的分支口径：`20040` 走长退避（30 秒）、`10002` 走短重试（5 秒，且**不显示为异常**）、其它失败走普通退避（5 秒）。
- 业务码一旦上线就是前端契约，新增码要走前端同步改动，不要复用旧码表达新语义。

### 2.4 限流：固定窗口计数 + 真实访客 IP

**限流键**：`xy_mbx_rate_` + 接口标识（`$bucket`）+ `_` + `md5( 访客 IP )`（哈希是为了不把明文 IP 写进 options 表键名）。

**窗口语义（关键，写错会必然撞限流）**：窗口自**本窗口内第一次请求**起算，后续请求只累加计数、**不延长窗口**。

反面教训（参考实现踩过）：早期写法是每次请求都把 TTL 重置为完整窗口；当轮询间隔（约 26 秒）短于窗口（60 秒）时，计数只增不减——一个标签页持续开着约 26 分钟后就会撞上限流并周期性提示「请求过于频繁」。

```php
/**
 * 获取访客 IP，用作限流维度。
 *
 * 站点位于 CDN / 反向代理之后时（如 Cloudflare），REMOTE_ADDR 是代理节点地址，若直接使用会导致
 * 所有访客共用同一个限流桶。此时需在 inc/config.php 里把 XY_MBX_PROXY_IP_HEADER 配置为真实 IP 的请求头
 * （Cloudflare 用 'HTTP_CF_CONNECTING_IP'），本函数会在该头中取第一个合法 IP，取不到再退回 REMOTE_ADDR。
 *
 * @return string
 */
function xy_mbx_get_client_ip() {
	$ip = '';

	if ( '' !== XY_MBX_PROXY_IP_HEADER && ! empty( $_SERVER[ XY_MBX_PROXY_IP_HEADER ] ) ) {
		$forwarded = sanitize_text_field( wp_unslash( $_SERVER[ XY_MBX_PROXY_IP_HEADER ] ) );

		// 该头可能是逗号分隔的列表（如 X-Forwarded-For），取第一个合法 IP。
		foreach ( explode( ',', $forwarded ) as $candidate ) {
			$candidate = trim( $candidate );

			if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
				$ip = $candidate;
				break;
			}
		}
	}

	if ( '' === $ip && isset( $_SERVER['REMOTE_ADDR'] ) ) {
		$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
	}

	return '' !== $ip ? $ip : 'unknown';
}

/**
 * IP 限流判断（固定窗口计数）。
 *
 * 设计意图：以「接口标识 + IP 哈希」为 transient 键计数，超出规则上限时拒绝；
 * IP 先做哈希，避免把明文 IP 写进 options 表键名。
 * 窗口规则：窗口自本窗口内**第一次**请求起算，后续请求只累加计数、**不再延长窗口**。
 *
 * @param string $bucket 接口标识，对应 XY_MBX_RATE_RULES 的键名。
 * @return bool true 表示已超限。
 */
function xy_mbx_is_rate_limited( $bucket ) {
	$rules = XY_MBX_RATE_RULES;
	$limit = isset( $rules[ $bucket ] ) ? (int) $rules[ $bucket ] : 0;

	if ( $limit <= 0 ) {
		return false;
	}

	$key   = 'xy_mbx_rate_' . $bucket . '_' . md5( xy_mbx_get_client_ip() );
	$count = (int) get_transient( $key );

	if ( $count >= $limit ) {
		return true;
	}

	// 本窗口剩余时长：窗口未开启（或使用外部对象缓存导致读不到超时项）时按完整窗口起算。
	$timeout = (int) get_option( '_transient_timeout_' . $key );
	$ttl     = ( $count > 0 && $timeout > time() ) ? ( $timeout - time() ) : (int) XY_MBX_RATE_WINDOW;

	set_transient( $key, $count + 1, max( 1, $ttl ) );

	return false;
}
```

**注意事项**

- 剩余窗口时长从 `_transient_timeout_<key>` option 反查；用**外部对象缓存**（Redis/Memcached）时读不到该 option，此时按完整窗口起算（退化为「续期」）——这是已知取舍，功能不受影响。
- 规则里没有的 `$bucket` 视为不限流（`$limit <= 0` 直接放行），因此新增路由要同步在 `XY_MBX_RATE_RULES` 里加键。
- `'unknown'` 兜底 IP 会让所有取不到 IP 的请求共用一个桶，属于极端兜底（正常环境 `REMOTE_ADDR` 必然存在）。

**如何判断限流是否按真实 IP 分桶（CDN 场景必做）**

1. 规则法：把 `XY_MBX_RATE_RULES` 里 `mailbox` 临时改成 `1`，用**两个不同出口 IP**（本机 + 手机热点）各请求 1 次：

```powershell
1..3 | ForEach-Object {
  $r = Invoke-RestMethod -Method Post -Uri "https://你的域名/wp-json/mytheme/v1/mailbox"
  "{0}: code={1} msg={2}" -f $_, $r.code, $r.msg
}
```

- 若**另一个 IP 的第一次**返回 `10000` → 说明已按真实 IP 分桶；
- 若另一个 IP 第一次也返回 `20040` → 说明所有访客共用一个桶，`XY_MBX_PROXY_IP_HEADER` 没配或配错（回到 1.3 的说明修正）。

2. 数据法：直接看已经生成了几个限流桶（`mailbox` 桶的数量应≈不同访客 IP 数；**只有 1 条**就说明全站共用一桶）：

```bash
wp option list --search='_transient_xy_mbx_rate_mailbox_*' --fields=option_name
# 插件方式之外，也可以用 SQL：
# SELECT option_name FROM wp_options WHERE option_name LIKE '_transient_xy_mbx_rate_mailbox_%';
```

3. 验证完把 `mailbox` 规则改回 `5`。

### 2.5 长轮询去重锁（`flock` 文件锁）

**背景**：`/emails` 是长轮询，单次请求占用一个 PHP-FPM 进程约 26 秒（超时 40 秒）。多标签页 / 多设备看同一邮箱会把进程池占满，导致整站（含首屏）排队变慢。

**为什么用 `flock` 而不是 transient**：「先查再写临时键」在毫秒级并发下会双双放行（实测两个标签页同时轮询时，两个请求都做了 26 秒长轮询）；`flock` 由内核保证原子性。另一个好处是进程结束时文件句柄自动关闭、锁自动释放，PHP 进程被 kill 或致命错误也不会留下死锁，因此**不需要额外的超时兜底**。

**锁的维度是邮箱地址**，不是 IP：同一台设备的多个标签页、甚至不同设备看同一邮箱都该去重。

```php
/**
 * 抢占「同一邮箱的长轮询锁」。
 *
 * @param string $address 邮箱地址。
 * @return resource|bool 抢到锁时返回文件句柄（释放时回传该句柄），未抢到时返回 false。
 */
function xy_mbx_acquire_poll_lock( $address ) {
	$dir = trailingslashit( wp_upload_dir()['basedir'] ) . 'xy-mbx-locks';

	if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
		return true; // 目录不可用：放行，退化为不去重。
	}

	$handle = @fopen( trailingslashit( $dir ) . md5( $address ) . '.lock', 'c' );

	if ( false === $handle ) {
		return true; // 同上：放行。
	}

	if ( ! flock( $handle, LOCK_EX | LOCK_NB ) ) {
		fclose( $handle );

		return false;
	}

	return $handle;
}

/**
 * 释放长轮询锁。
 *
 * @param resource|bool $handle xy_mbx_acquire_poll_lock() 的返回值。
 * @return void
 */
function xy_mbx_release_poll_lock( $handle ) {
	if ( is_resource( $handle ) ) {
		flock( $handle, LOCK_UN );
		fclose( $handle );
	}
}
```

调用侧（在请求上游之前抢锁、上游返回后**无论成败立即释放**）：

```php
	$lock_handle = xy_mbx_acquire_poll_lock( $address );

	if ( false === $lock_handle ) {
		// 未抢到锁 = 已有请求正在长轮询，本次立即返回，由前端延后重试。
		// 10002 是本主题自定义业务码（上游不会返回该码），HTTP 仍为 200，
		// 前端据此走「稍后自动重试」分支，不显示「连接异常」。
		return xy_mbx_rest_response( 10002, '已有轮询进行中', null, 200 );
	}

	$client = new XY_MBX_Mailcx_Client();
	$result = $client->list_emails( $address, $since, $client_id );

	// 上游返回后（无论成功还是 WP_Error）立即释放锁，保证后续请求能发起真实长轮询。
	xy_mbx_release_poll_lock( $lock_handle );
```

**注意事项**

- 可用性优先：锁文件无法创建 / 打开（例如目录不可写）时返回 `true` **放行**——宁可退化为「没有去重」，也不能让收信功能整体不可用。
- 锁文件落在 `wp-content/uploads/xy-mbx-locks/<md5(address)>.lock`；数量与邮箱数同阶，很小，可随上传目录一起备份 / 清理。
- 该目录需要 PHP 可写（与 WordPress 上传目录要求一致）。目录被清空不会造成故障（下次请求重建）。

### 2.6 上游客户端：签名与公共参数（安全相关的固定口径）

参考实现：`inc/class-mailcx-client.php`，类名 `XY_MBX_Mailcx_Client`。

- 签名口径：取除 `sign` 外的全部参与参数 → 过滤值为 `null` / `''` 的项 → 按键名 ASCII 升序排序 → 拼成 `key=value&key=value`（**不做 urlencode**）→ 以 `XY_MBX_APP_SECRET` 为密钥做 HMAC-SHA256 → 输出小写 hex。
- 每次请求补齐公共参数 `app_id` / `timestamp` / `nonce` / `sign`；`nonce` 为 `bin2hex( random_bytes( 8 ) )`（16 位小写 hex），每次唯一。
- 响应处理：`code === 10000`（`self::CODE_SUCCESS`）才算成功，返回 `data` 字段；否则把上游 `code` 与 `msg` 包成 `WP_Error`，由 REST 层转成 `40001`。
- **上游 `code` / `msg` 可以透传给终端用户，`app_secret` 与签名原文绝不能出现在响应里。**

---

## 三、第三方 HTML 渲染的安全基线

场景：邮件正文是**上游可控的第三方 HTML**。它可能带 `<script>`、`on*` 事件、`javascript:` 链接、钓鱼 `<form>`、外链追踪像素。

**为什么不能直接 `echo` 上游 HTML**：如果把它注入到同源页面（`innerHTML` / 直接输出），脚本会在本站域下执行——可以读取 `localStorage`（本主题用它存当前邮箱地址）、篡改页面、伪造界面。因此必须**双重隔离**：

1. **服务端**：kses 白名单过滤，剥掉一切可执行内容（进不到前端）；
2. **前端**：`sandbox=""` 的无脚本 iframe 内渲染（即使漏网也无法执行 / 无法同源访问宿主页）。

### 3.1 服务端：扩展 kses 白名单（`inc/rest-api.php`）

背景：`wp_kses_post()` 的白名单不含 `iframe` / `source` / `picture` / `embed` / `style`，邮件里的视频、音频、内嵌播放器与内联样式会被整段剥离（`video` / `audio` 失去 `<source>` 后无法播放）。

安全边界（沿用 kses 默认行为，不因扩展白名单而放开）：`script`、`form` / `input` / `button`、`link`、`meta`、`base` 仍不在白名单内；`on*` 事件属性未放行，会被剥离；`javascript:` 等非白名单协议会被 kses 清空；`data:` 协议同样不放行（内联 base64 图片会被剥离）。

```php
/**
 * 过滤邮件 HTML 正文：在 WordPress 文章白名单基础上放行邮件常见的媒体与排版标签。
 *
 * @param string $html 邮件原始 HTML 正文。
 * @return string 可直接下发给前端的 HTML。
 */
function xy_mbx_sanitize_email_html( $html ) {
	$allowed = wp_kses_allowed_html( 'post' );

	// 媒体类标签的放行属性（含 iframe 播放器所需的 allow / allowfullscreen 等）。
	$media_attr = array(
		'src'             => true,
		'srcset'          => true,
		'sizes'           => true,
		'poster'          => true,
		'controls'        => true,
		'preload'         => true,
		'autoplay'        => true,
		'loop'            => true,
		'muted'           => true,
		'playsinline'     => true,
		'width'           => true,
		'height'          => true,
		'style'           => true,
		'class'           => true,
		'id'              => true,
		'title'           => true,
		'type'            => true,
		'media'           => true,
		'alt'             => true,
		'loading'         => true,
		'referrerpolicy'  => true,
		'allow'           => true,
		'allowfullscreen' => true,
		'frameborder'     => true,
		'href'            => true,
		'target'          => true,
		'rel'             => true,
	);

	$extra_tags = array(
		'iframe'  => $media_attr,
		'video'   => $media_attr,
		'audio'   => $media_attr,
		'embed'   => $media_attr,
		'object'  => $media_attr,
		'picture' => $media_attr,
		'source'  => $media_attr,
		'track'   => array(
			'src'     => true,
			'kind'    => true,
			'srclang' => true,
			'label'   => true,
			'default' => true,
		),
		'param'   => array(
			'name'  => true,
			'value' => true,
		),
		'a'       => array(
			'href'   => true,
			'target' => true,
			'rel'    => true,
		),
		// <style>：邮件排版大量依赖内联样式表，需整体保留（可执行内容仍由 script 未放行兜住）。
		'style'   => array(
			'type'  => true,
			'media' => true,
		),
	);

	// 与核心默认白名单合并，避免覆盖掉核心已放行的属性（如 object 的 data）。
	foreach ( $extra_tags as $tag => $attrs ) {
		$base            = isset( $allowed[ $tag ] ) ? (array) $allowed[ $tag ] : array();
		$allowed[ $tag ] = array_merge( $base, $attrs );
	}

	// button 属于核心文章白名单，但它在邮件场景里没有任何可用行为（form 已被剥离、
	// 正文又在无脚本的 sandbox iframe 内渲染），按需求一并剥离。
	unset( $allowed['button'] );

	$filtered = wp_kses( (string) $html, $allowed );

	// cid:（Content-ID 内联图片）与 data:（内联 base64）都不是浏览器可解析的外部地址：kses 只会去掉
	// 协议头，留下 "image001@abc" / "image/png;base64,..." 这类相对地址，浏览器会当成站内路径去请求
	// 而渲染成破图。这里把「src 既不是 http(s) 也不是协议相对地址」的 <img> 整段删除（邮件里的相对
	// 路径同样无法解析）；保持实现最小：不做内联附件替换。
	$cleaned = preg_replace( '/<img\b[^>]*\bsrc\s*=\s*(["\'])\s*(?!(?:https?:)?\/\/)[^"\']*\1[^>]*>/i', '', $filtered );

	return is_string( $cleaned ) ? $cleaned : $filtered;
}
```

调用点（`GET /email` 回调里，**下发给前端之前**统一过滤）：

```php
	if ( is_array( $result ) ) {
		if ( isset( $result['html_body'] ) ) {
			$result['html_body'] = xy_mbx_sanitize_email_html( (string) $result['html_body'] );
		}
	}
```

**注意事项**

- 必须走 `wp_kses_allowed_html( 'post' )` 起手再**合并**扩展项（`array_merge( $base, $attrs )`）；直接手写一份完整数组会丢掉核心已放行的属性（例如 `object` 的 `data`）。
- 白名单只加「展示类」标签与属性，**不加** `script` / `form` / `input` / `button` / `link` / `meta` / `base`，也不加任何 `on*`。
- 放宽白名单不能靠自觉，交付前用一段含 `<script>alert(1)</script>`、`<img src=x onerror=alert(1)>`、`<a href="javascript:alert(1)">` 的测试邮件过一遍，确认三者都被剥离。
- 上游偶发「`html_body` 为空、`text_body` 实际是 HTML 源码」：表现为前端把标签当纯文本显示（「HTML 没渲染」）。参考实现做了**最小判定**（仅当 `html_body` 为空且 `text_body` 明显含 HTML 标签时，才把过滤后的 `text_body` 提升为 `html_body`，`text_body` 原样保留）——注意这条路径也必须走同一个 `xy_mbx_sanitize_email_html()`，不要绕过过滤。

### 3.2 前端：`sandbox` iframe（`assets/js/mailbox.js`）

```js
/**
 * 用 sandbox iframe 渲染邮件 HTML 正文。
 * sandbox=""（无脚本、无同源）与宿主页面彻底隔离；正文已在服务端做过扩展白名单过滤。
 */
function renderHtmlFrame( html ) {
	// sandbox 内不允许顶层导航，<base target="_blank"> 让邮件里的链接点击后在新标签打开。
	var srcdoc = '<!DOCTYPE html><html><head><meta charset="utf-8"><base target="_blank">'
		+ '<style>' + MAIL_FRAME_BASE_CSS + '</style></head><body>' + html + '</body></html>';

	var frame = document.createElement( 'iframe' );

	frame.className = 'xy-mail-frame';
	frame.setAttribute( 'sandbox', '' );
	frame.setAttribute( 'title', '邮件正文' );
	frame.srcdoc = srcdoc;

	el.detailBody.appendChild( frame );
}
```

配套的 `MAIL_FRAME_BASE_CSS` 只保证可读性与不出现横向滚动：`html{overflow-x:hidden}`、`img{max-width:100%}`、`table{max-width:100%}` 等。

**注意事项**

- `sandbox=""`（**空值 = 施加全部限制**）是最严格档位：禁脚本、禁同源、禁表单提交、禁顶层导航、禁弹窗。**不要**为了“让某些邮件正常显示”而加 `allow-scripts` / `allow-same-origin`——那等于放弃隔离。
- 用 `srcdoc` 而不是 `src=blob:`/`data:`：`srcdoc` 天然同源文档但不共享脚本上下文，且不需要额外的 CSP 配置。
- `<base target="_blank">` 是必需的：sandbox 禁顶层导航，不加重定向的话邮件里的链接点了没反应。
- 渲染入口必须走「有 `html_body` → iframe；无 → 纯文本（`textContent`）」分支，纯文本分支**不要**用 `innerHTML`。

---

## 四、性能约定

### 4.1 静态资源版本号用 `filemtime()`

改文件即刷新缓存，不需要手动改版本号，也不会出现「代码已更新但浏览器还在用旧 JS/CSS」：

```php
$script_relative_path = 'assets/js/mailbox.js';
$script_absolute_path = XY_MBX_THEME_DIR . $script_relative_path;

// 文件不存在时不注册，避免产生 404 请求。
if ( file_exists( $script_absolute_path ) ) {
	wp_enqueue_script(
		'xy-mbx-mailbox',
		XY_MBX_THEME_URI . $script_relative_path,
		array(),
		(string) filemtime( $script_absolute_path ),
		true
	);
}
```

**注意事项**

- 版本号用 `(string) filemtime( $绝对路径 )`，路径必须是**服务端绝对路径**（`XY_MBX_THEME_DIR` 前缀），不是 URL。
- 每个资源入库前都先 `file_exists()`：产物缺失时不要注册，避免多一个 404 请求（样式产物缺失时的兜底见 4.2）。
- 上 CDN 时建议对 `assets/` 设长 `expires`——URL 会随 `filemtime` 变化，长缓存是安全的。

### 4.2 预编译样式 + 关闭运行时编译

- `assets/css/tailwind.css` 是 **Tailwind 4 + daisyUI 5 的预编译产物**（`npm run build:css` 生成），随主题分发 → 首屏即带完整样式，**无样式闪烁**，前台不下载编译器、不依赖 `esm.sh`。
- 若目标站点装了 WindPress：产物**存在时**关闭其前台运行时编译，避免首屏结构与样式滞后；产物**缺失时**不拦截，继续由 WindPress 兜底（不会出现「完全无样式」）。

```php
/**
 * 关闭 WindPress 的前台运行时编译（仅在本主题的预编译产物存在时生效）。
 * 说明：该过滤器只影响前台，WindPress 后台与编辑器功能不受影响。
 */
function xy_mbx_disable_windpress_runtime() {
	return file_exists( XY_MBX_THEME_DIR . 'assets/css/tailwind.css' );
}
add_filter( 'f!windpress/core/runtime:is_prevent_load', 'xy_mbx_disable_windpress_runtime' );
```

**例外**：任何 Tailwind / daisyUI 类名的新增或改动都必须在开发机重新执行 `npm run build:css` 并上传新产物（详见 `02-样式与构建.md`）。

### 4.3 transient 缓存策略（附失败兜底）

| 缓存键 | 缓存内容 | TTL 常量（默认） | 失败 / 空结果时的行为 |
| --- | --- | --- | --- |
| `xy_mbx_domains` | 可用域名列表 | `XY_MBX_DOMAINS_CACHE_TTL`（600） | 回退内置 `XY_MBX_FALLBACK_DOMAINS`，并**缓存这份兜底结果**，避免每次请求都等一次超时 |
| `xy_mbx_mail_<md5(email_id)>` | 邮件详情（不可变） | `XY_MBX_MAIL_DETAIL_CACHE_TTL`（600） | 失败**不写缓存**，避免把上游短暂异常固化 |
| `xy_mbx_friend_links`（`XY_MBX_FRIEND_LINKS_CACHE_KEY`） | 友情链接列表 | `XY_MBX_FRIEND_LINKS_CACHE_TTL`（3600） | 失败或空列表写入**空数组短缓存**（`XY_MBX_FRIEND_LINKS_FAIL_TTL`，300 秒），页面按「无友链」渲染，防止每次访问都打上游 |
| `xy_mbx_rate_<bucket>_<md5(ip)>` | 限流计数 | `XY_MBX_RATE_WINDOW`（60） | 无（计数用途，见 2.4） |

**通用约定**

- 缓存只存**成功结果**（邮件详情、友链正例）；失败要么不写，要么写一个短 TTL 的空值把请求挡住。
- 命中空数组也算命中缓存：`if ( is_array( $cached ) ) { return $cached; }`——用 `! empty()` 判断会让空缓存永远失效，等于没有兜底。
- 缓存的是「上游数据的最终形态」（归一化后的字段），不要缓存 `WP_Error` 或未过滤的 HTML。

### 4.4 长轮询超时与服务器超时（三层必须递增）

| 层 | 值 | 说明 |
| --- | --- | --- |
| 上游无新邮件时的挂起时长 | 约 25–26 秒 | 上游行为，不可控 |
| `XY_MBX_TIMEOUT_LONG_POLL` | 40 秒 | 主题侧长轮询超时，**必须大于上游挂起时长**，否则误判为失败 |
| `fastcgi_read_timeout`（nginx） | ≥ 60 秒 | 必须大于主题侧超时，否则 PHP 已被 nginx 切断（宝塔默认 300 秒，一般无需改） |

**注意事项**

- 三层任一处不满足，现象都是「一直等待新邮件 / 偶尔连接异常」，排查顺序从上游挂起时长往下核对。
- 长轮询必然占用 PHP-FPM 进程，因此必须配合 2.5 的 `flock` 去重（否则多标签页会占满进程池，拖慢整站）。
- 前端在页面切到后台时暂停轮询、多标签同邮箱互相去重，这两点与服务端锁共同兜住进程占用。

### 4.5 友链服务端渲染（不要改成异步）

页脚友链由 PHP 服务端渲染进 HTML（取数在 `inc/friend-links.php`，渲染在 `footer.php`），**禁止改成前端 JS 异步请求**：① 搜索引擎蜘蛛能直接抓到链接；② 上游耗时与调用配额被小时级缓存挡在页面渲染之外。上游失败时返回空数组、不抛错、不中断渲染。

---

## 五、部署流程

### 5.1 打包（PowerShell，含排除项与顶层目录校验）

在**主题目录的上一级**（通常是 `wp-content/themes/`）执行：

```powershell
# 打包：在 wp-content/themes/ 下执行
$theme   = 'mytheme'
$version = '1.0.0'
$zip     = "mytheme-v$version.zip"
$stage   = '._build_stage'

# 0) 前置检查：预编译样式产物必须存在（缺失则前台无样式，见 4.2）
if (-not (Test-Path "$theme\assets\css\tailwind.css")) { throw "缺少 assets/css/tailwind.css，请先在主题目录执行 npm run build:css" }

# 1) 清理上次残留
Remove-Item $stage, $zip -Recurse -Force -ErrorAction SilentlyContinue

# 2) 复制成干净副本：排除构建依赖、版本库目录、工作物料（设计稿/草稿/日志/历史包）
robocopy $theme "$stage\$theme" /E /XD node_modules .git /XF *.zip *.log *.psd *.ai .DS_Store | Out-Null
if ($LASTEXITCODE -ge 8) { throw "robocopy 失败，退出码 $LASTEXITCODE" }

# 3) 打包（Compress-Archive 会把 $theme 目录本身作为 zip 的顶层目录）
Compress-Archive -Path "$stage\$theme" -DestinationPath $zip -Force

# 4) 校验 zip 顶层只有单一主题目录
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip))
$tops    = $archive.Entries | ForEach-Object { ($_.FullName -split '/')[0] } | Sort-Object -Unique
$hasNode = ($archive.Entries.FullName -like '*/node_modules/*').Count
$archive.Dispose()
"顶层目录: $($tops -join ', ')"
"node_modules 条目数: $hasNode"

# 期望：顶层目录只有一行 mytheme；node_modules 条目数为 0
if ($tops.Count -ne 1 -or $tops[0] -ne $theme) { throw "zip 顶层目录不唯一，WordPress 会安装失败" }
if ($hasNode -gt 0) { throw "zip 内仍含 node_modules" }

# 5) 清理临时目录
Remove-Item $stage -Recurse -Force
```

**注意事项**

- **zip 顶层必须是单一主题目录**（`xiaoying-mailbox/…`）：多个顶层条目（例如把 `style.css`、`inc/` 直接放在根）会让 WordPress 后台上传时报「主题缺少 style.css」，或解压出一堆散落文件。
- 排除项至少包含：`node_modules`（构建依赖，体积是主题的数十倍）、`.git`、历史 zip、日志、以及本项目自留的工作物料目录 / 文件。**排除项按需追加，不要为了“干净”删掉 `assets/css/tailwind.css`**。
- 打包前必须确认 `assets/css/tailwind.css` 已构建且是最新版（改过类名没重建 = 上线即样式错误）。
- 打包后先在本机解压到临时目录，确认 `xiaoying-mailbox/style.css` 与 `xiaoying-mailbox/assets/css/tailwind.css` 都在，再上传。

### 5.2 上传覆盖安装

- **方式 A（后台上传）**：后台 →「外观 → 主题 → 安装主题 → 上传主题」→ 选择 zip → 已存在时选择「替换现有版本」。
- **方式 B（解压覆盖）**：解压到 `wp-content/themes/`，确认得到 `wp-content/themes/xiaoying-mailbox/`（顶层目录名不能变），主题自动保持启用。
- **升级前先备份**：主题目录 + 数据库（主题会写文章 / 页面 / 选项，但不会碰站点骨架，回滚只需恢复目录）。
- **凭证**：若凭证写在 `wp-config.php`（推荐，见 1.2），覆盖主题不会丢配置；若写在 `inc/config.php`，升级后要确认生产凭证仍在。

### 5.3 上线检查清单（逐条勾选）

**主题与基础**

- [ ] 主题已启用，后台「外观 → 主题」能看到预览图（`screenshot.png`）
- [ ] 前台有完整样式（无样式闪烁）：`assets/css/tailwind.css` 已被加载，Network 里无 404
- [ ] 页面源码里 CSS / JS 的 `?ver=` 是刷新后的时间戳（`filemtime` 生效）
- [ ] 浏览器控制台无 JS 报错，无横向滚动条

**配置与接口**

- [ ] `wp-config.php`（或 `inc/config.php`）已改成**生产**的 API 基址与凭证，且 `XY_MBX_API_BASE` 结尾无 `/`
- [ ] 首页点「获取临时邮箱」能拿到 `xxx@域名`（说明签名与网络都通）
- [ ] `XY_MBX_PROXY_IP_HEADER` 已按「站点是否在 CDN 之后」配置正确（Cloudflare = `HTTP_CF_CONNECTING_IP`），并用 2.4 的「两个 IP」方法验证过限流是按真实 IP 分桶
- [ ] 向刚拿到的地址发一封测试邮件，30 秒内出现在收件箱
- [ ] 打开这封邮件能看到正文（HTML 在 iframe 内渲染、图片 / 视频正常）

**内容与模板**

- [ ] 「设置 → 阅读」已指定静态首页与文章页，导航「文章」链接可点
- [ ] 文章列表显示封面图与摘要；文章详情顶部有面包屑、底部有相关文章
- [ ] 分类 / 标签归档可访问，搜索可用，页头页脚品牌图标正常

**SEO**（详见 `06-SEO与Yoast集成.md`）

- [ ] Yoast SEO 已启用；首页 `<title>` / `description` / `canonical` 正确（查看源码）
- [ ] `canonical` / `og:url` / `ld+json` / `meta name="keywords"` 各只有一份；搜索页 noindex
- [ ] `/sitemap_index.xml` 与 `/robots.txt` 可访问，且域名 / 协议为 `https://生产域名`

**服务器**

- [ ] nginx：`fastcgi_read_timeout ≥ 60s`；`/wp-json/` 未被 WAF 拦截；`error_page 404` 未顶掉 WordPress 404 页
- [ ] `wp-content/uploads/` 可写（长轮询锁目录 `xy-mbx-locks/` 能被创建）

---

## 六、常见故障排查表

| 现象 | 排查方向 |
| --- | --- |
| 前台完全没有样式 | ① `wp-content/themes/你的主题/assets/css/tailwind.css` 是否存在（缺失 → 开发机 `npm run build:css` 后重传）；② 页面源码里的 `<link ... tailwind.css?ver=...>` 是否 404；③ 改过类名但没重新构建 |
| 拿不到邮箱 / 返回 `40001 上游服务调用失败` | ① `XY_MBX_API_BASE` 是否结尾多了 `/`、是否可达（服务器上 `curl` 一下）；② `XY_MBX_APP_ID` / `XY_MBX_APP_SECRET` 是否与生产环境一致；③ 看 `msg` 里的上游码（`20011` = 签名失败）；④ 服务器出网 / 证书链问题 |
| 页面频繁提示「请求过于频繁」 | ① 站点在 CDN 后但 `XY_MBX_PROXY_IP_HEADER` 未配 → **全站共用一个桶**（用 2.4 的方法验证）；② 同出口 IP 下多标签页吃满配额；③ `XY_MBX_RATE_RULES` 是否被调得过小；④ 确认限流是**固定窗口**（若每次请求都续期，计数只增不减，必撞限流） |
| 长轮询失败 / 一直「等待新邮件」 | ① 测试地址是否已过期（上游邮件约 1 小时失效）；② nginx `fastcgi_read_timeout` 是否 ≥ 60 秒（要大于 `XY_MBX_TIMEOUT_LONG_POLL`）；③ 直接请求 `/wp-json/xiaoying-mailbox/v1/emails?address=...` 看返回码；④ REST 路径是否被 WAF / 安全插件拦截 |
| 偶现「已有轮询进行中」（`10002`） | 正常设计：同一邮箱同时只保留 1 个上游长轮询（保护 PHP-FPM 进程池），前端 5 秒后自动重试，不显示为异常 |
| 邮件 HTML 白屏 / 无样式 / 图片是破图 | ① 安全策略会剥离 `script`、`data:` 内联图片，`cid:` 内嵌图片（`src` 非 http(s)）整段删除不留破图；② 若 `html_body` 为空而 `text_body` 是 HTML，主题会做最小提升后再过滤；③ 检查 `/email` 返回 JSON 里 `html_body` 是否为空；④ 确认响应经过 `xy_mbx_sanitize_email_html()`（绕过过滤的白名单外标签会被剔除） |
| 页脚友链不显示 | ① 上游未返回启用中的链接（空列表）→ 属正常空态；② 命中了 300 秒的空缓存（等 5 分钟或删 `xy_mbx_friend_links` transient）；③ 上游报错看 `msg` |
| favicon 还是旧的 | 浏览器强缓存 → Ctrl+F5；仍不行则确认 `header.php` 里的 favicon 路径与新文件是否存在（换了文件名缓存问题会立刻暴露） |
| 404 页面变成服务器默认页 | nginx 里 `error_page 404 /404.html;` + `fastcgi_intercept_errors on;` 顶掉了 WordPress 404（主题 `404.php` 与 Yoast 的 404 noindex 都不再生效），注释掉这两行 |
| 固定链接打不开 / 除首页都 404 | 固定链接结构不含 `index.php` 时需服务器重写规则：nginx `location / { try_files $uri $uri/ /index.php?$args; }` |
| `robots.txt` / sitemap 里还是旧域名或 http | ① 站点地址改对后走 Yoast → 工具 → 重新索引；② **CDN 会缓存 `robots.txt`**，改域名后必须清一次 CDN 缓存 |
| 锁文件目录不可写 | 表现为去重失效（功能仍可用）；确认 `wp-content/uploads/` 可写，或检查 `xy-mbx-locks/` 的属主 / 权限 |

---

## 七、交付前自检（本分册相关的 4 条硬性要求）

1. **密钥边界**：`app_secret` 只出现在 `wp-config.php` / `inc/config.php`，前端只拿到 `restUrl`。
2. **输入边界**：任何对外参数都先 `trim` + 类型转换 + 白名单校验，再限流、再请求上游。
3. **内容边界**：第三方 HTML 必须「服务端 kses 过滤 + 前端 `sandbox` iframe」双重隔离，两个都不能省。
4. **配置边界**：所有可变值集中在 `inc/config.php` 且用 `defined()` 保护；不在业务代码里硬编码地址、密钥、超时、TTL。
