# FastAdmin CMS 文章自动发布

广东高职高考信息网 (gdcjgk.net) 使用 ThinkPHP + FastAdmin CMS + Nginx。

## 项目信息

- GitHub: `Keibott/gdcjgk.net-copy`
- 服务器: Nginx，web root = `public/`（不是项目根目录）
- 数据库: `cms_qsfw_com`，表前缀 `fa_`
- 文章表: `fa_cms_archives`
- CMS 管理模型: `app/admin/model/cms/Archives`
- 栏目模型: `addons/cms/model/Channel`（表 `fa_cms_channel`）
- CMS 配置表: `fa_config`，CMS 插件设置以 JSON 存于 `value` 字段

## 推荐方案：使用 CMS 内置 API

CMS 自带发布接口：`POST /addons/cms/api/index`，无需额外文件，不涉及 Nginx 重写。

### 1. 设置 API Key

后台 CMS 插件配置中 `apikey` 字段可能不可见（默认空）。直接写数据库：

```sql
UPDATE fa_config SET value = '{"apikey":"hermes2026"}' WHERE name = 'cms' AND `group` = 'cms';
```

> ⚠️ `group` 是 MySQL 保留字，必须用反引号包裹。

### 2. 调用

```bash
# 请求格式: 传统 form POST（不是 JSON！）
curl -X POST https://gdcjgk.net/addons/cms/api/index \
  -d "apikey=hermes2026&channel_id=227&title=测试标题&content=<p>正文</p>&description=摘要"
```

**必填字段：** `apikey`, `title`, `content`, `channel_id`（或 `channel`）

**推荐用 `channel_id`**：栏目树中有大量同名子栏目（如多个「报考相关」），`channel` 名称匹配可能选错。

### ⚠️ 关键陷阱：link 型栏目不能发布文章

`fa_cms_channel` 有三种类型：
| type | 说明 | 可发布文章 |
|------|------|----------|
| `channel` | 内容栏目 | ✅ |
| `list` | 列表型栏目 | ✅ |
| **`link`** | 链接跳转型 | ❌ |

**channel_id=1（「政策资讯」）是 `link` 类型**（outlink 指向 `/shengtingxiaoxi.html`）。虽然 API 返回 `code:1` 说「新增成功」，文章确实写入了 `fa_cms_archives` 表，但**不会生成独立页面 URL**，访问都是 404。

**验证方法：** 发布前先确认栏目类型，不是 `link`：
```sql
SELECT id, type, name FROM fa_cms_channel WHERE id = <channel_id>;
```

### 3. 栏目 ID 速查

| 分类 | 叶子栏目 | ID | type |
|------|---------|-----|------|
| 3+证书 | 报考相关 | 227 | list |
| 3+证书 | 填报相关 | 226 | list |
| 3+证书 | 技能证书 | 180 | list |
| 3+证书 | 报考须知 | 282 | list |
| 3+证书 | 填报须知 | 280 | list |
| 依学考 | 报考相关 | 294 | list |
| 依学考 | 填报相关 | 295 | list |
| 依学考 | 报考须知 | 237 | list |
| 依学考 | 填报须知 | 234 | list |
| 政策 | 广东政策 | 198 | list |
| 政策 | 政策解读 | 331 | list |
| 政策 | 省厅消息 | 301 | list |
| 升学 | 现代学徒制 | 262 | list |
| 升学 | 自主招生 | 263 | list |
| 升学 | 三二分段 | 193 | list |
| 升学 | 五年一贯制 | 279 | list |
| 辅导 | 辅导培训 | 176 | list |
| 考前辅导 | (根栏目) | 4 | channel |
| 招生动态 | (根栏目) | 32 | list |

⚠️ channel_id=1（「政策资讯」）、22（「网站首页」）**不可用** — 都是 `link` 类型。

完整栏目树见数据库 SQL 文件。

## 备选方案：独立 PHP 文件（api_publish.php）

当 CMS 内置 API 不可用时，在 `public/` 放独立文件。

### 关键陷阱

1. **文件必须放 `public/` 目录**（web root），且 PHP 中路径用 `../application/` 和 `../thinkphp/`
2. **PHP 5.x 兼容**：不能使用 `??` null coalescing 操作符，改用 `isset()`；不能用 `[]` 短数组语法，用 `array()`
3. **Nginx 重写拦截**：`public/.htaccess` 有 Apache 重写规则排除已存在文件，但 Nginx 不会读取 `.htaccess`。独立 PHP 文件可能被 Nginx 重写交给 CMS `index.php`，返回首页 HTML 而非 JSON。**修复**：在 Nginx server 块加 `location = /api_publish.php { include enable-php.conf; }`

### 完整代码（兼容 PHP 5.x，放 `public/api_publish.php`）

```php
<?php
define('APP_PATH', __DIR__ . '/../application/');
define('BIND_MODULE', '');
require __DIR__ . '/../thinkphp/start.php';

header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit(json_encode(array('code' => 0, 'msg' => '仅支持POST')));
}

$raw = file_get_contents('php://input');
$input = json_decode($raw, true);
if (!$input || (isset($input['token']) ? $input['token'] : '') !== 'hermes2026') {
    http_response_code(403);
    exit(json_encode(array('code' => 0, 'msg' => '鉴权失败')));
}

if (empty($input['title']) || empty($input['content'])) {
    http_response_code(400);
    exit(json_encode(array('code' => 0, 'msg' => '缺少必填字段')));
}

if (empty($input['channel_id']) && empty($input['channel'])) {
    http_response_code(400);
    exit(json_encode(array('code' => 0, 'msg' => '缺少 channel_id 或 channel')));
}

try {
    if (!empty($input['channel_id'])) {
        $channel = \addons\cms\model\Channel::get(intval($input['channel_id']));
    } else {
        $channel = \addons\cms\model\Channel::where('name', $input['channel'])->find();
    }
    if (!$channel || $channel['status'] != 'normal') {
        exit(json_encode(array('code' => 0, 'msg' => '栏目未找到')));
    }

    $model = \addons\cms\model\Modelx::get($channel['model_id']);
    \think\Db::startTrans();
    $archives = new \app\admin\model\cms\Archives;
    $data = array(
        'channel_id'  => $channel['id'],
        'model_id'    => $model['id'],
        'title'       => $input['title'],
        'content'     => $input['content'],
        'description' => isset($input['description']) ? $input['description'] : '',
        'image'       => isset($input['image']) ? $input['image'] : '',
        'user_id'     => isset($input['user_id']) ? $input['user_id'] : 1,
        'publishtime' => time(),
        'weigh'       => 0,
        'status'      => 'normal',
    );
    $archives->allowField(true)->save($data);
    \think\Db::commit();

    echo json_encode(array(
        'code' => 1, 'msg' => 'OK',
        'data' => array('id' => $archives->id, 'url' => $archives->fullurl)
    ), JSON_UNESCAPED_UNICODE);
} catch (\Exception $e) {
    \think\Db::rollback();
    http_response_code(500);
    echo json_encode(array('code' => 0, 'msg' => $e->getMessage()));
}
```

### 测试

从 Hermes 服务器测试连通性后再调用：

```bash
# 先测连通
curl -s -o /dev/null -w "%{http_code}" https://gdcjgk.net/

# 调用 API
curl -X POST https://gdcjgk.net/api_publish.php \
  -H "Content-Type: application/json" \
  -d '{"token":"hermes2026","channel_id":227,"title":"测试","content":"<p>测试</p>"}'
```

### ⚠️ 发布后延迟

API 返回成功不代表页面立即可访问。CMS 有 URL 路由缓存，通常需等待 **2-5 秒** 后页面才返回 200。发布脚本应在 API 返回后 `sleep 3` 再验证。

## 文章缩略图（image 字段）

CMS 的 `image` 字段可选，不留空会显示空白。三种方案：

| 方案 | 做法 | 优点 | 缺点 |
|------|------|------|------|
| 留空 | 不传 `image` | 简单 | 列表页显示空白缩略图 |
| 统一默认图 | 上传一张通用图到后台，发布时固定引用 | 可控 | 所有文章同一张图 |
| **随机图库** ✅ | 从 `fa_attachment` 表提取历史图片 URL，发布时随机选取 | 每篇文章不同图，视觉丰富 | 需预建图库 |

### 构建图库（推荐方案）

从数据库备份 SQL 提取所有上传图片 URL：

```python
import re, json

with open("数据库备份.sql") as f:
    content = f.read()

pattern = r"INSERT INTO `fa_attachment` VALUES \((.*?)\);"
matches = re.findall(pattern, content, re.DOTALL)

image_urls = []
for match in matches:
    url_match = re.search(r"'((?:/uploads/|/assets/img/)\S+?\.(?:jpg|jpeg|png|gif|webp))'", match)
    if url_match:
        url = url_match.group(1)
        if 'qrcode' in url.lower():
            continue
        image_urls.append(f"https://gdcjgk.net{url}")

# 去重保存
image_urls = list(set(image_urls))
with open("~/.hermes/data/image_library.json", "w") as f:
    json.dump(image_urls, f)
```

**图库文件：** `~/.hermes/data/image_library.json`（从 2024-05 备份提取约 4,179 张）

### 发布时随机选取

```python
import json, random

with open("/home/agentuser/.hermes/data/image_library.json") as f:
    library = json.load(f)

image_url = random.choice(library)

# curl 发布时传入 image 参数
curl -X POST https://gdcjgk.net/addons/cms/api/index \
  -d "channel_id=227&title=标题&content=<p>正文</p>&image=${image_url}"
```

**注意：** 图库来自数据库备份，部分图片可能已被删除（返回 404）。发布脚本应做 fallback：如果传入的 `image` 返回 404，CMS 前端通常会自动降级，不影响文章展示。

## 栏目树分析

从数据库备份 SQL 解析栏目结构。SQL 中 `INSERT INTO fa_cms_channel VALUES` 格式：

```
(id, 'type', model_id, pid, 'name', 'image', 'flag', ...)
```

`pid=0` 为一级栏目。`type='link'` 为链接型栏目（不可发布文章）。叶子栏目 = 无子栏目 + type ≠ 'link'。

Python 解析示例见项目代码 `parse_channels()` 函数。
