---
name: fastadmin-publish
description: Publish content to FastAdmin/ThinkPHP CMS via API — controller creation, routing pitfalls, Nginx/Apache rewrite gotchas, and database-driven config patterns.
---

# FastAdmin CMS Content Publishing

## When to use
- Auto-publishing AI-generated content to a FastAdmin CMS website
- Bypassing broken/buggy CMS addon APIs
- Creating custom API endpoints in ThinkPHP 5 / FastAdmin

## Built-in API Endpoint (simplest path)

The CMS addon exposes a built-in API at:

```
POST https://<domain>/addons/cms/api/index
Content-Type: application/x-www-form-urlencoded
```

### Parameters

| Param | Required | Description |
|-------|----------|-------------|
| `channel_id` | ✅ | Numeric leaf channel ID |
| `title` | ✅ | Article title |
| `content` | ✅ | HTML body content |
| `description` | No | Summary/excerpt |
| `image` | No | Thumbnail path |
| `user_id` | No | Author user ID (default: 1) |
| `status` | No | `draft` = save as draft, omit/`normal` = publish |
| `apikey` | No | API key (may be disabled — see below) |

Response: `{"code":1,"msg":"新增成功","data":{"id":"3734","url":"https://..."}}`

### Critical Pitfall: xss_clean strips channel_id

The default CMS `Api.php` applies `xss_clean` filter to all POST data:
```php
$data = $this->request->post('', null, 'trim,xss_clean');
```

This filter **strips numeric values** like `channel_id`, causing:
```
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'channel_id' cannot be null
```

**Fix** — In `addons/cms/controller/Api.php`, change line 51:
```php
// Before
$data = $this->request->post('', null, 'trim,xss_clean');
// After
$data = $this->request->post('', null, 'trim');
```

Also ensure the `else` branch sets `$data['channel_id']`:
```php
} else {
    $channel = Channel::get($this->request->request('channel_id'));
    if (!$channel || $channel['status'] != 'normal') {
        $this->error('栏目未找到');
    }
    $data['channel_id'] = $channel->id;  // ← required
}
```

### API Key Bypass

The CMS API checks an `apikey` field but if the admin panel doesn't show the setting (default empty), comment out both auth checks in `_initialize()`:
```php
// if (!$config['apikey']) { ... }
// if ($config['apikey'] != $apikey) { ... }
```

### Channel ID Reference

⚠️ Always use `channel_id` (int), never `channel` (name string). Multiple channels share the same name at different tree levels. Leaf channels (no children) are the publish targets. **channel_id=1 is type='link' (redirect) — articles created there 404.** Only use `type='channel'` or `type='list'` channels.

See `references/gdcjgk-channels.md` for verified publish targets.

### Finding Channels from Database
```bash
# Extract leaf channels from SQL dump
grep -oP "VALUES \(\d+, '\w+', \d+, 0, '\K[^']+" dump.sql | sort -u

# Build full tree
python3 -c "
import re
with open('dump.sql', 'r') as f: content = f.read()
records = re.findall(r'INSERT INTO \`fa_cms_channel\` VALUES \((\d+).*?(\d+), \d+, \'([^\']+)\'', content)
# ...build tree...
"
```

### CURL Test
```bash
# Publish directly
curl -s -X POST "https://gdcjgk.net/addons/cms/api/index" \
  -d "channel_id=227&title=测试标题&content=<p>测试正文</p>&description=摘要"

# Save as draft (not public)
curl -s -X POST "https://gdcjgk.net/addons/cms/api/index" \
  -d "channel_id=227&title=测试标题&content=<p>测试正文</p>&description=摘要&status=draft"
# Review URL: https://gdcjgk.net/rCIwxpQnNh.php/cms/archives/edit/ids/{id}
```

## Custom Controller (when built-in API is broken)

The most reliable approach is a controller inside the CMS addon at `addons/cms/controller/`:

```php
// addons/cms/controller/Publish.php
namespace addons\cms\controller;

class Publish extends Base
{
    public function index()
    {
        // Simple token auth
        if (($_POST['token'] ?? '') !== 'YOUR_TOKEN') {
            return json(['code' => 0, 'msg' => '鉴权失败']);
        }
        
        $channel = \addons\cms\model\Channel::get(intval($_POST['channel_id']));
        $model = \addons\cms\model\Modelx::get($channel['model_id']);
        
        \think\Db::startTrans();
        $archives = new \app\admin\model\cms\Archives;
        $archives->allowField(true)->save([
            'channel_id'  => $channel['id'],
            'model_id'    => $model['id'],
            'title'       => $_POST['title'],
            'content'     => $_POST['content'],
            'description' => $_POST['description'] ?? '',
            'user_id'     => 1,
            'publishtime' => time(),
            'weigh'       => 0,
            'status'      => 'normal',
        ]);
        \think\Db::commit();
        
        return json(['code' => 1, 'msg' => 'OK', 'data' => ['id' => $archives->id, 'url' => $archives->fullurl]]);
    }
}
```

URL: `https://SITE/addons/cms/publish/index`

## Common pitfalls

### 1. Standalone PHP files get intercepted by Nginx
- Putting `api.php` in `public/` — Nginx rewrites ALL requests to `index.php`
- `.htaccess` has `RewriteCond %{REQUEST_FILENAME} !-f` but Nginx ignores it
- **Solution**: Use framework controllers, not standalone files

### 2. FastAdmin config table is tricky
- CMS addon config stored in `fa_config` table as JSON
- `get_addon_config('cms')` reads both `config.php` defaults and DB overrides
- Setting `apikey` via SQL `INSERT INTO fa_config (name, group, value)` often fails due to caching or merge logic
- **Solution**: Don't fight with config table. Use custom controller with hardcoded token.

### 3. ThinkPHP routing
- `application/route.php` controls URL routing
- API module may not be active by default (domain binding commented out)
- CMS addon routes are defined in `application/extra/addons.php` under `route` key
- Custom controllers in `addons/cms/controller/` auto-register — no route config needed
## CMS Content Formatting (曹小芬 2026-07-01)

All article HTML pasted into the backend editor MUST follow these rules:

| Element | Style |
|---------|-------|
| Body | `text-align:justify; line-height:2; font-family:'Microsoft YaHei','微软雅黑',sans-serif; font-size:16px; color:#000;` |
| Headings (h2) | `text-indent:2em; font-weight:bold;` |
| Paragraphs (p) | `text-indent:2em;` |

Wrap everything in `<div style="...all body styles...">` — paste that div into the editor.

Template: `templates/article-format.html`

## Channel Categorization Rules (曹小芬 2026-07-01)

| Article Type | Channel | ID |
|-------------|---------|-----|
| 考试政策、通知、考试历 | 政策信息 > 广东政策 | 198 |
| 语文备考技巧/经验 | 复习资料 > 语文 > 备考经验 | 216 |
| 数学备考技巧/经验 | 复习资料 > 数学 > 备考经验 | 219 |
| 英语备考技巧/经验 | 复习资料 > 英语 > 备考经验 | 222 |
| 语文资料分享 | 复习资料 > 语文 > 资料分享 | 215 |
| 数学资料分享 | 复习资料 > 数学 > 资料分享 | 218 |
| 英语资料分享 | 复习资料 > 英语 > 资料分享 | 221 |
| 3+证书报考 | 3+证书 > 报考相关 | 227 |
| 3+证书填报 | 3+证书 > 填报相关 | 226 |
| 技能证书 | 3+证书 > 技能证书 | 180 |

**DO NOT** put 备考/复习类 articles in 考前辅导 > 辅导培训 (176).

## Channel matching

- Always use `channel_id` (int), never `channel` (name string)
- Multiple channels can share the same name at different tree levels
- Leaf channels (no children) are the publish targets — content in parent channels won't display
- **⚠️ channel_id=1 is type='link' (redirect), NOT a publish target!** Articles created there write to DB but never get page URLs (404). Use type='channel' or 'list' channels only.
- Channel reference: `references/gdcjgk-channels.md`
- Full channel tree: `references/gdcjgk-channels-full.md`

## Pre-publish review notification

Before publishing, send a WeCom notification for human review:
```bash
python3 ~/.hermes/scripts/pre_publish_review.py '[{"title":"...","channel":"栏目名","channel_id":227,"url":"https://..."}]'
```
Uses webhook `d4de64e1-efaa-4f03-a833-ad85c1d6de5b`. Formats as markdown with backend edit link.
- **⚠️ BEWARE of `type='link'` channels** (e.g. channel_id=1 政策资讯). They redirect to an `outlink` URL and NEVER generate article pages — API returns success but all articles 404. Filter: only use `type='channel'` or `type='list'`. Check `references/gdcjgk-channels.md` for verified targets.
- Real channels in gdcjgk.net: 4=考前辅导, 32=招生动态, 176=辅导培训, 198=广东政策, 226=3+证书填报相关, 227=3+证书报考相关, 294=依学考报考相关

## Database reference

| Table | Purpose |
|-------|---------|
| `fa_cms_archives` | Articles (posts) |
| `fa_cms_channel` | Categories/channels (tree via `pid`) |
| `fa_config` | System + addon configuration |
| `fa_cms_model` | Content models |

Channel tree structure: `id`, `type` (channel/list/link), `pid` (parent ID), `name`.

## Thumbnail

Random image from site's own `fa_attachment` history:
```python
import json, random
with open('/home/agentuser/.hermes/data/image_library.json') as f:
    images = json.load(f)
image = random.choice(images)  # 4,179 images from fa_attachment table
```
Pass as `image` param to the publish API. Images are from `https://gdcjgk.net/uploads/...` — already hosted, no upload needed.

## Data cross-reference for content

When building articles that need admission data (majors, scores, schools, enrollment numbers), use the technique in `references/data-cross-reference.md` — cross-references the `2025` diyform table (2,731 majors) against the `fa_cms_archives` model_id=2 school table (106 schools), matched with external data sources.

## Publishing workflow (草稿 + 人工审核)

### 0. Pre-publish fact verification (MANDATORY — do NOT skip)

Before creating the draft, load the `fact-check` skill and verify:
- All dates/times against official sources (eea.gd.gov.cn, 微信公众号「广东省教育考试院」)
- All numbers (录取分数, 排位, 招生计划) against local CSV data first, then official policy documents
- All exam names, institution names, and policy terms are exact (no paraphrasing from memory)

**PITFALL**: Creating a draft before verifying facts means the reviewer (user) will catch errors you should have caught. The user expects the draft to be accurate — they are reviewing for editorial quality, not fact-checking from scratch. If the user has to ask "where did this date come from?", you failed step 0.

### 1. Create draft
```python
data = {
    "channel_id": "226",    # from gdcjgk-channels.md
    "title": "...",
    "content": "<p>...</p>",
    "image": "https://gdcjgk.net/uploads/xxx.jpg",  # random from image_library.json
    "status": "draft",      # DRAFT — not publicly visible
    "diyname": "slug-name"
}
urllib.request.urlopen(urllib.request.Request(
    "https://gdcjgk.net/addons/cms/api/index",
    data=urllib.parse.urlencode(data).encode()
))
```

### 2. Verify draft (should return 404)
```bash
curl -s -o /dev/null -w "%{http_code}" "https://gdcjgk.net/channel-slug/ARTICLE_ID.html"
# Expected: 404 (draft = not public)
```

### 3. Send WeCom review notification
```bash
curl -s "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=***" \
  -H "Content-Type: application/json" \
  -d '{"msgtype":"markdown","markdown":{"content":"## 🔔 新文章待审核\n...\n📝 [后台编辑](https://gdcjgk.net/rCIwxpQnNh.php/cms/archives/edit/ids/ID)"}}'
```

### 4. Reviewer changes status from draft → normal in backend

### Testing
```bash
curl -X POST "https://SITE/addons/cms/api/index" \
  -d "channel_id=227&title=测试&content=<p>测试</p>&status=draft"
```

## Multi-Domain FastAdmin

A single FastAdmin installation can serve multiple domains (e.g. `gdcjgk.net` and `gaozhigaokao.net` pointing to the same backend). Admin sessions may be shared across domains if they share the same PHP session storage, but **credentials can differ per domain** — user `gdcjgk` may exist on one domain but not the other. Always confirm which domain the user is logged into before requesting cookies.

**RELIABLE ADMIN ENTRY: `gaozhigaokao.net`.** The `gdcjgk.net` domain is behind Cloudflare with aggressive WAF that blocks datacenter IPs (returns Cloudflare 403, error code 1010), making server-side API calls impossible. Use `gaozhigaokao.net` for admin access and API calls when available. Note: `gaozhigaokao.net` may have API key protection enabled (`"请先在后台配置API密钥"`) while `gdcjgk.net` has it commented out — the two domains may run slightly different code copies even though they share the database.

### Pitfall: User's browser cookie doesn't work from server

Even with the correct `PHPSESSID` from the user's browser, curl requests from the server will be rejected ("请登录后操作"). PHP sessions can be bound to the client's IP, User-Agent, or other server-side validation. **Do not spend time debugging cookie transfer** — if the API is unreachable from the server, fall back to providing the formatted HTML at a public URL and instructing the user to paste it into the backend editor manually via `F12` → Console → `document.cookie` → copy → paste HTML.

### Pitfall: gdcjgk.net API blocked from datacenter

When `gdcjgk.net` is protected by Cloudflare, the server cannot reach `/addons/cms/api/index` (returns Cloudflare 403). Fallback options in order:
1. Try `gaozhigaokao.net/addons/cms/api/index` if it shares the same database
2. If that requires API key, provide HTML at `https://rss.gdcjgk.net/static/<article>.html` for manual paste\n\n## Admin Panel Login (editing existing articles)

The built-in API only **creates** articles — it cannot update or read existing ones. Editing requires admin panel access. However, if `xss_clean()` was removed from the codebase (as in Api.php), the admin login may also be broken.

### Pitfall: AdminLog.php xss_clean breaks login

If `xss_clean()` was stripped from the CMS but `AdminLog.php` still calls it, the admin login flow crashes at step 5 below, returning HTTP 500 even with correct credentials:

1. CSRF token check → OK
2. Captcha validation → OK
3. Username/password check → OK
4. Session initialization → partial
5. **AdminLog hook records login** → **FATAL: `Call to undefined function xss_clean()`**
6. Response → HTTP 500, session NOT committed

**How to tell**: if login returns 500 with `xss_clean` in the error body, the credentials WERE correct but the hook crashed. The PHP session cookies (`PHPSESSID`, `server_name_session`) are set but contain NO admin authentication — you cannot access the dashboard with them.

**Server-side fix** — In `/www/wwwroot/<site>/application/admin/model/AdminLog.php` line 83, replace:
```php
'url' => substr(xss_clean(strip_tags(request()->url())), 0, 1500),
```
with:
```php
'url' => substr(strip_tags(request()->url()), 0, 1500),
```

**Workaround** — If the user has an active admin session in their browser, ask for their `PHPSESSID` and `server_name_session` cookies. The most reliable way to get both at once is via the browser Console (`F12` → Console → `document.cookie`), which returns all cookies as a single semicolon-separated string. The Application tab sometimes hides or filters cookies; `document.cookie` is foolproof.

Use the cookies to authenticate curl requests to the admin panel:
```bash
curl -s -b "PHPSESSID=xxx; server_name_session=yyy" \
  "https://SITE/rCIwxpQnNh.php/cms/archives/edit/ids/3809"
```

**Captcha reading** — FastAdmin login requires a 4-character captcha. Read it with Qwen-VL via the `bl` CLI:
```bash
curl -s -c /tmp/cookies.txt 'https://SITE/rCIwxpQnNh.php/index/login' > /tmp/login.html
TOKEN=$(grep -oP 'name="__token__" value="\K[^"]+' /tmp/login.html)
curl -s -b /tmp/cookies.txt 'https://SITE/index.php?s=/captcha' -o /tmp/captcha.png
CAPTCHA=$(bl vision analyze --image /tmp/captcha.png \
  --prompt "Read ONLY the 4-character captcha code. Output exactly 4 chars, preserve case." \
  2>&1 | python3 -c "import sys,json; print(json.load(sys.stdin)['choices'][0]['message']['content'].strip())")
```

### Image hosting for article insertion

When the admin panel is unreachable but images need to be inserted into articles:

**PRIMARY PATH — Use `rss.gdcjgk.net/static/`** (Cloudflare-tunneled, reliably accessible):

```bash
# Copy image to we-mp-rss static directory
cp /path/to/image.png /home/ubuntu/project/we-mp-rss-main/static/image_name.png
```

Provide the user with the `<img>` tag:
```html
<img src="https://rss.gdcjgk.net/static/image_name.png" alt="描述" style="max-width:100%; border-radius:8px; margin:16px 0;">
```

**ALWAYS verify the URL before telling the user:**
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://rss.gdcjgk.net/static/image_name.png
# Must return 200
```

**FALLBACK — `files.gdcjgk.net` (port 8088 HTTP server)** — Only use as backup; often blocked by Cloudflare:
```bash
cp /path/to/image.png ~/.hermes/image_name.png
# URL: https://files.gdcjgk.net/image_name.png
```

**PITFALL: `files.gdcjgk.net` 403 from Cloudflare** — This domain consistently returns Cloudflare edge 403 (`server: cloudflare`, `cf-ray` header) while `rss.gdcjgk.net` works through the same tunnel. Root cause is Cloudflare DNS proxy/WAF configuration, not the tunnel. Do NOT spend time debugging the tunnel — use the `rss.gdcjgk.net/static/` workaround immediately.

**PITFALL: HTTP server on 8088 hangs silently** — Python's `http.server` on port 8088 can enter a state where the process is alive (`ps aux` shows it) but all requests return "Empty reply from server" (curl exit code 52). Fix: kill the hung process and restart:
```bash
# Find and kill hung server
ps aux | grep 'http.server 8088' | grep -v grep | awk '{print $2}' | xargs kill
# Start fresh (use background=true in Hermes terminal)
cd ~/.hermes && python3 -m http.server 8088 --bind 0.0.0.0
```

See `references/image-hosting-workaround.md` for full troubleshooting flow including Cloudflare tunnel restart and 403 diagnosis.

See `references/admin-login-debugging.md` for a full session transcript including API exploration, cookie investigation, and the full PHP error trace from the xss_clean crash.
