三
三股水
三
三股水

Typecho Restful 插件 + Obsidian 对接排错全记录

本文从 Obsidian 撰写发布
# Typecho Restful 插件 + Obsidian 对接排错全记录

> 记录一次 "同样的插件、同样的 PHP 版本,一个站点能用,另一个站点不能用" 的完整排查过程。
> 最终根因:**PHP 8.1 的 Deprecated 警告污染了 JSON 响应体,导致客户端 `JSON.parse` 失败**。

---

## 一、问题现象

- 新站(Typecho 1.2/1.3 + PHP 8.2 + Restful 插件)对接 Obsidian 失败
- Obsidian 报错:

  ```
  Request failed: /userList
  SyntaxError: Unexpected token '<', "<br />
  <b>"... is not valid JSON
      at JSON.parse (<anonymous>)
  ```

- 老站用**一模一样的插件文件**、**同样的 PHP 版本**,Obsidian 能正常使用

---

## 二、初始错误日志

```
2026/10/07 09:51:08 [error] 5855#0: *1773 FastCGI sent in stderr: 
"PHP message: PHP Deprecated: str_replace(): Passing null to parameter #3 ($subject) 
of type array|string is deprecated 
in /www/wwwroot/linjianziran.com/usr/plugins/Restful/Action.php on line 124" 
while reading response header from upstream, 
client: 182.247.151.182, server: www.linjianziran.com, 
request: "GET /index.php/api/userList HTTP/2.0", 
upstream: "fastcgi://unix:/tmp/php-cgi-82.sock:", host: "www.linjianziran.com"
```

关键信息:
- **PHP 8.2**
- **`str_replace()` 第 3 个参数 `$subject` 收到了 `null`**
- 位置:`Action.php` **第 124 行**
- 请求:`GET /index.php/api/userList`

---

## 三、逐步排查过程

### 步骤 1:确认服务端是否收到请求

```bash
curl -i "https://www.linjianziran.com/index.php/api/userList"
```

返回:

```
HTTP/2 400
access-control-allow-credentials: true

<br />
<b>Deprecated</b>: str_replace(): Passing null to parameter #3 ($subject) ... on line 124<br />
{"status":"error","message":"\u975e\u6cd5\u8bf7\u6c42\uff01","data":null}
```

分析:
- **HTTP 400** 而不是 404 → 路由注册成功
- 返回 JSON → Action 类被正确调用
- **`非法请求!`** → 触发了 `sendCORS()` 开头的 Origin 检查

### 步骤 2:定位 "非法请求!" 的来源

在 `Action.php` 里搜索 `非法请求`,只出现在 `sendCORS()`:

```php
$httpOrigin = $this->request->getServer('HTTP_ORIGIN');
if (!$httpOrigin) {
    $this->throwError('非法请求!');   // ← 卡在这里
}
```

原因:**curl 默认不带 `Origin` 头**。

### 步骤 3:带上 Origin 再测

```bash
curl -i -H "Origin: app://obsidian.md" \
     "https://www.linjianziran.com/index.php/api/userList"
```

返回:

```
HTTP/2 403
<br />
<b>Deprecated</b>: str_replace(): Passing null to parameter #3 ($subject) ... on line 124<br />
{"status":"error","message":"apiToken is invalid","data":null}
```

分析:
- **CORS 已过**(不再报"非法请求!")
- 新问题:**`apiToken is invalid`** → 请求没带 token

### 步骤 4:带上正确 token 再测

从 Typecho 后台 Restful 插件设置里查到 `apiToken` 是 `123456a`:

```bash
curl -i -H "Origin: app://obsidian.md" \
     -H "token: 123456a" \
     "https://www.linjianziran.com/index.php/api/userList"
```

返回:

```
HTTP/2 200
access-control-allow-credentials: true

<br />
<b>Deprecated</b>: str_replace(): Passing null to parameter #3 ($subject) ... on line 124<br />
{"status":"success","message":"","data":[{"uid":1,"mail":"...","screenName":"xige"}]}
```

分析:
- **HTTP 200,数据正确返回** → 服务端所有校验都过了
- **但响应体前面仍然挂着 HTML 警告** → 这就是 Obsidian 解析失败的根因

---

## 四、根因分析

### 1. 触发点:Action.php 第 124 行

```php
foreach ($reflectClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectMethod) {
    $methodName = $reflectMethod->getName();

    preg_match('/(.*)Action$/', $methodName, $matches);
    if (isset($matches[1])) {
        $routes[] = array(
            'action' => $matches[0],
            'name' => 'rest_' . $matches[1],
            'shortName' => $matches[1],
            'uri' => $prefix . $matches[1],
            'description' => trim(str_replace(
                array('/', '*'),
                '',
                substr($reflectMethod->getDocComment(), 0, strpos($reflectMethod->getDocComment(), '@'))
            )),
        );
    }
}
```

问题代码:

```php
substr($reflectMethod->getDocComment(), 0, strpos($reflectMethod->getDocComment(), '@'))
```

- 如果某个 `xxxAction()` 方法**没有 docComment** → `getDocComment()` 返回 `false`
- 如果注释里**没有 `@`** → `strpos()` 返回 `false`
- `substr($doc, 0, false)` 在 PHP 8.1+ 会**产生 Deprecated 警告**
- 这个警告被**输出到 HTTP 响应体**,插在 JSON 前面

### 2. 为什么警告会污染 JSON

- PHP 的 `display_errors = On` 时,**警告会直接输出到响应体**,而不是只写日志
- Restful 插件的 `throwJson()` 只是 `echo json_encode(...)`,**不会清理之前的输出**
- 于是响应体变成:

  ```
  <br />
  <b>Deprecated</b>: str_replace(): ...<br />
  {"status":"success","data":[...]}
  ```

- 任何调用 `JSON.parse()` 的客户端(浏览器 fetch、Obsidian、curl + jq)都会**因为第一个字符是 `<` 而失败**:

  ```
  SyntaxError: Unexpected token '<', "<br />
  <b>"... is not valid JSON
  ```

### 3. 为什么老站能用,新站不能用

| 项目 | 老站 | 新站 |
|------|------|------|
| Action.php 第 124 行 bug | 有(但之前修过或关了 display_errors) | 有 |
| PHP 版本 | 8.2 | 8.2 |
| **`display_errors`** | **Off** | **On** |
| 警告去向 | 写入日志文件 | **输出到响应体** |
| JSON 是否干净 | ✅ 干净 | ❌ 被污染 |
| Obsidian 能否解析 | ✅ 能 | ❌ 不能 |

**同样的代码 + 同样的 PHP 版本,`display_errors` 不同,行为完全不同。**

### 4. 为什么触发它的方法和请求无关

- `getRoutes()` 会**反射遍历所有 `xxxAction()` 方法**
- 只要**任何一个方法**没写完整注释(比如 `postArticleAction` 之类后加的方法)
- **所有 API 请求**都会触发警告

**请求 `userList`,却被 `postArticleAction` 的注释缺失干掉了。**

---

## 五、修复方案

### 方案 A:修改 Action.php(推荐,治本)

打开 `usr/plugins/Restful/Action.php`,找到 `getRoutes()` 方法,把循环体整段替换:

**原来代码**:

```php
foreach ($reflectClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectMethod) {
    $methodName = $reflectMethod->getName();

    preg_match('/(.*)Action$/', $methodName, $matches);
    if (isset($matches[1])) {
        $routes[] = array(
            'action' => $matches[0],
            'name' => 'rest_' . $matches[1],
            'shortName' => $matches[1],
            'uri' => $prefix . $matches[1],
            'description' => trim(str_replace(
                array('/', '*'),
                '',
                substr($reflectMethod->getDocComment(), 0, strpos($reflectMethod->getDocComment(), '@'))
            )),
        );
    }
}
```

**修复后**:

```php
foreach ($reflectClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectMethod) {
    $methodName = $reflectMethod->getName();

    preg_match('/(.*)Action$/', $methodName, $matches);
    if (isset($matches[1])) {
        $doc = $reflectMethod->getDocComment() ?: '';
        $pos = strpos($doc, '@');
        $desc = ($pos === false) ? $doc : substr($doc, 0, $pos);
        $routes[] = array(
            'action' => $matches[0],
            'name' => 'rest_' . $matches[1],
            'shortName' => $matches[1],
            'uri' => $prefix . $matches[1],
            'description' => trim(str_replace(array('/', '*'), '', $desc)),
        );
    }
}
```

改动点:
1. `getDocComment() ?: ''` —— 保证不是 `false`
2. `$pos === false` 显式判断 —— 避免 `substr($doc, 0, false)`
3. 让 `str_replace` 第三个参数永远是字符串

保存即可,**不需要重启 PHP-FPM**。

### 方案 B:关闭 display_errors(治标)

```ini
; /www/server/php/82/etc/php.ini
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
```

```bash
systemctl restart php-fpm-82
```

或者宝塔面板:软件商店 → PHP 8.2 → 设置 → 配置修改 → `display_errors = Off` → 保存 → 重载 PHP。

**不推荐**:会把其他真实错误也藏起来,且 PHP 9 会让这类 warning 变成致命错误。

### 方案 C:给所有 Action 方法写完整注释(辅助)

```php
/**
 * 获取用户列表
 * @return void
 */
public function userListAction() { ... }
```

有 `/**` 和 `@`,`getDocComment()` 和 `strpos` 就不会返回 `false`,也就不会触发警告。

---

## 六、验证修复

改完代码后:

```bash
curl -i -H "Origin: app://obsidian.md" \
     -H "token: 123456a" \
     "https://www.linjianziran.com/index.php/api/userList"
```

**期望返回**(`{` 之前无任何 HTML):

```
HTTP/2 200
access-control-allow-credentials: true

{"status":"success","message":"","data":[{"uid":1,"mail":"...","screenName":"xige"}]}
```

响应体以 `{"status"` 开头,无 `<br />` `<b>Deprecated</b>`,即修复成功。

---

## 七、Obsidian 侧配置

| 配置项 | 值 |
|--------|-----|
| **Host** | `https://你的域名/index.php/api`(**必须带 `/index.php/api`,末尾不能有斜杠**) |
| **Token** | 与 Typecho 后台 Restful 插件设置里的 `apiToken` **完全一致** |
| **User** | 网络通畅后,下拉框自动加载 |

> 若站点配了 Nginx 伪静态,也可以用 `https://你的域名/api`,但要确认 rewrite 规则已生效。

---

## 八、经验总结

### 1. Deprecated 警告 ≠ 无害

```
Deprecated 警告  →  输出到响应体  →  JSON 污染  →  JSON.parse 失败  →  API 完全不可用
```

警告本身不致命,但**污染响应体**会击穿下游所有严格解析客户端。

### 2. `display_errors` 是"同样代码不同行为"的元凶

| `display_errors` | 警告去向 | 影响 |
|------------------|----------|------|
| `On` | HTTP 响应体 | 污染 JSON,客户端崩溃 |
| `Off` | error_log 文件 | JSON 干净,客户端正常 |

**生产环境务必 `display_errors = Off`。**

### 3. 反射遍历的坑

`getRoutes()` 用反射扫描所有 `xxxAction()` 方法,**任何一个方法注释缺失,所有 API 请求都会报错**。触发点和请求目标无关,很难猜。

**所有被反射的方法都应写完整 docComment。**

### 4. `substr($x, 0, false)` 是 PHP 经典坑

- PHP 7:静默处理
- PHP 8.0:类型检查
- **PHP 8.1:Deprecated 警告**
- PHP 9:**将直接抛 TypeError**

嵌套使用 `substr` / `strpos` / `str_replace` 时,务必处理 `false` 返回值。

### 5. 请求先分层验证

排错时不要一上来就"改配置",按顺序验证:

```
1. 服务端是否收到请求(curl 基础测试)
2. 路由是否注册(HTTP 400 ≠ 404)
3. CORS 是否通过(带 Origin 头)
4. Token 是否匹配(带 token 头)
5. 响应体是否合法(响应开头必须是 '{' 或 '[')
6. 客户端解析是否成功(Obsidian 报错具体行号)
```

每一层单独定位,比"猜配置"高效得多。

### 6. 生产环境 API 加防御

在输出 JSON 之前,清掉之前的所有输出:

```php
if (ob_get_length()) {
    ob_clean();
}
```

或在插件入口开 `ob_start()`,退出前 `ob_end_clean()` 再输出。

---

## 九、一句话总结

> **一个反射注释缺失,加上 `display_errors = On`,让 PHP 8.1 的 Deprecated 警告被当作 HTML 输出到 JSON 前面。浏览器看着没事,任何用 `JSON.parse` 的客户端全部崩溃。老站因为关过警告输出所以正常。改代码或关 `display_errors` 均可修复,改代码是治本。**

---

## 十、完整修复清单

- [ ] 修改 `usr/plugins/Restful/Action.php` 的 `getRoutes()` 方法(方案 A)
- [ ] 或/并且 关闭生产环境 `display_errors`(方案 B)
- [ ] 给所有 `xxxAction()` 方法补全 docComment(方案 C,辅助)
- [ ] 后台 Restful 设置里配置好 `origin` 域名列表
- [ ] 确认 `apiToken` 与 Obsidian 插件一致
- [ ] Obsidian Host 填 `https://域名/index.php/api`(**末尾无斜杠**)
- [ ] curl 验证响应体以 `{"status"` 开头
- [ ] Obsidian 重新添加站点测试
上一篇 Obsidian 小红书同步插件(Xiaohongshu Sync)今日修复与 AI 模块开发记录 下一篇 试用免费typecho新主题Robes

暂无评论

Ctrl + Enter 发送

还没有评论,来说点什么吧

© 2026 三股水