模板语法概览
TradeAdmin 的前台由 pongo2(Django 风格)模板引擎驱动。模板就是一份普通的 HTML,标签写在 HTML 里,渲染时被替换成数据。文件名后缀必须是 .html;变量与字段严格区分大小写,字段首字母大写——{{ archive.Title }} 对,{{ archive.title }} 取不到。
1. 模板里能用的四类东西
| 类别 | 数量 | 写法 |
|---|---|---|
| 业务标签 | 34 个 | {% archiveList ... %} |
| 覆写的 set | 1 个 | {% set x = 1 %},比内置版多一个 global = |
| 自定义过滤器 | 17 个(另覆写 split / wordwrap) | {{ 值|dateFormat:"2006-01-02" }} |
| 全局函数 | 4 个 | {{ stampToDate(t, "2006-01-02") }} |
| pongo2 内置标签 / 过滤器 | 23 + 52 | {% if %} · {{ v|truncatechars:40 }} |
标签与过滤器是进程级全局注册,一次注册全站可用;4 个全局函数挂在模板引擎实例上,只有前台渲染链路才有。
2. 单值标签的两种写法
判据是标签名后面第一个词是不是 with:是就回退去解析参数(直接把值输出到页面),不是就把它当成变量名(只赋值、页面什么都不出现)。
第一个标识符就是 with,值写进页面。
{% system with name='SiteName' %}
给了变量名,页面不输出,后面自己用。
{% system siteName with name='SiteName' %} {{ siteName }}
块标签(列表类)必须给变量名;闭合标签写 {% endarchiveList %} 即可,也可以写 {% endarchiveList articles %}——写了名字就必须和开头一致。只有 jsonLd(匿名块)与 jump(位置参数)不需要变量名。
3. 参数的四条硬规则
with必须写、必须小写。写了参数却漏写with,绝大多数标签会在解析期直接报Malformed xxx-tag arguments.,不是静默忽略。- 参数名大小写敏感。
moduleid=1等于没写,必须写moduleId。 - 字符串值必须加引号。裸写
name=Title会被当变量解析,取不到就是空值而不是报错——这是最常见的「参数传了没反应」。 - 布尔参数只认关键字
true/false。render="true"是字符串,标签按布尔读会得到 false。 - 未知参数被静默忽略:没有中心化的参数白名单,名字打错不报错、只是没生效。
4. 渲染上下文里的变量
| 变量 | 在哪些页面有 | 说明 |
|---|---|---|
website | 全部 | 站点上下文,标签靠它取配置与服务。取不到时标签一律静默返回:不报错、不输出、块体也不渲染 |
webInfo | 全部 | 本页信息:Title / PageName / CurrentPage / NavBar 等 |
urlParams | 全部 | 当前地址栏的全部查询参数,筛选器与文档访问密码也读它 |
archive / category / module | 对应的详情页 / 列表页 | 当前文档 / 分类 / 内容模型实体 |
page | 单页 | 当前单页分类实体 |
tag | 标签详情页 | 当前标签实体 |
q | 搜索页 | 搜索关键词 |
userId / userInfo / userGroup | 登录会员访问时才有 | 匿名访问(含搜索引擎抓取)这三个键根本不存在,模板里必须判空 |
带登录身份的请求会绕过静态缓存——会员态字段(是否收藏、是否已购)让页面「随人而变」,匿名访客看到的是缓存版本,登录会员看到的是现渲染版本。
5. 转义
{{ 变量 }} 输出会被 HTML 转义,要出 HTML 必须加 |safe;而标签自己写出来的内容不转义,所以 {% pageDetail with name='Content' %} 可以直接当 HTML 用。render / lazy 两个过滤器返回的是 HTML,也必须配 |safe。{# 单行注释 #} 只能写一行——里面换行会让整页渲染成一行错误文本,而 HTTP 状态码仍然是 200,只看状态码会误判。模板目录与页面级覆盖
前台模板放在 resource/template/<模板名>/,模板自带的 css / js / 图片放在 resource/public/static/<模板名>/(对应网址 /static/<模板名>/…)。静态资源引用一律走 {% system with name='TemplateUrl' %},换模板后路径才不会断。
resource/template/<模板名>/ ├── config.json # 模板包信息(后台「模板设计」列表显示用) ├── base.html # 全站骨架:head / 导航 / 页脚 + {% block %} 挖坑 ├── index/index.html # 首页 ├── article/{index,list,detail}.html # 模块首页 / 分类列表 / 详情 ├── product/{index,list,detail}.html # 同上(目录名 = 模块别名) ├── case/{list,detail}.html # 分类绑定用的自定义模板 ├── page/{about,service,detail}.html # 单页 ├── search/index.html # 搜索结果 ├── tag/{index,list}.html # 标签首页 / 标签详情 ├── errors/404.html # 404 └── partial/ # include 用的零件:面包屑 / 分页 / 侧栏
引擎取模板的三级优先
| 顺序 | 来源 | 什么时候用 |
|---|---|---|
| 1 | 磁盘 resource/template/<模板名>/ | 正常情况;改完磁盘文件调一次 template_reload 即生效 |
| 2 | 配置 cms.templateDir 指的目录 | 开发调试临时覆盖 |
| 3 | 编译进二进制的 default 主题 | 前两级都没有时兜底 |
resource/template/default 整个删掉,前台不会白屏,而是回退到二进制里那份出厂模板渲染——但只有模板兜底,静态文件不兜底,resource/public/static/default/ 删了样式和图片就真没了。页面地址与模板文件的对应关系
列表里的模板按顺序找,第一个存在的就用;一个都没找到就走 404。{模块别名} 就是模块的 URL 别名(后台「内容模型」里可改可加),它既是网址里的那一段,也是模板目录名。
| 页面 | 模板候选(按优先级) |
|---|---|
| 首页 | index/index.html → index.html |
| 文档详情 | 分类绑定的详情模板 → {模块别名}/detail.html → {模块别名}_detail.html |
| 模块首页 | {模块别名}/index.html → {模块别名}_index.html |
| 分类列表 | 分类绑定的列表模板 → {模块别名}/list.html → {模块别名}_list.html |
| 单页 | 分类绑定的列表模板 → page/{别名}.html → {别名}/index.html → page/detail.html |
| 搜索 | search/{模块别名}.html → search/index.html |
| 标签首页 / 标签详情 | tag/index.html · tag/list.html(另有 tag_*.html 兜底) |
| 404 | errors/404.html → 404.html |
固定结构之外的页面怎么做
分类绑定模板
分类编辑里填「列表模板 / 详情模板」,写模板包下的相对路径(如 case/list.html)。自己没填就往父级找,但那个祖先必须勾了「继承」才生效。
单页
新建一个类型为「单页」的分类并填好别名,再建 page/{别名}.html,内容用 {% pageDetail with name="Content" %} 取。
纯静态页
把 .html 直接丢进 resource/public/,访问 /about.html 即可。静态文件优先于路由,不会被伪静态规则抢走。
common/{名}.html 这类「自定义模板页」在本产品里用不了:路由规则表里没有 common 这一条,代码里的 CommonPage 是死代码。别照着其它文档去建 common/ 目录。标签总览 35 个
共 35 个模板标签(34 个业务标签 + 覆写的 set),分 4 组。点击标签名可跳转到详细说明。
过滤器与全局函数同样做成了卡片,见下方 过滤器速查表 与 函数速查表 之后的卡片区。模板能拿到的数据,前台公开 API 基本都能拿到 —— API 的完整清单在 前台公开 API 一节。
自定义过滤器速查 19 个
用法一律是 {{ 值|名字 }} 或 {{ 值|名字:参数 }}——没有 |名字(a,b) 这种写法,参数只有一个位置,要传多值就写成逗号分隔的字符串由过滤器自己切。内置的 split / wordwrap 已被换成自研实现。
下表是一览;每一项的完整卡片(参数表、示例、注意事项)见下方「自定义过滤器」一节,卡片内容来自后台模型「模板过滤器与函数」,改文档只需改后台。
| 过滤器 | 参数 | 作用与边界 |
|---|---|---|
contain | |contain:值 | 包含判断:struct 比字段名、map 比 key、string 比子串、slice 比元素;其它类型返回 false |
trim | |trim 或 |trim:"削减集" | 无参去首尾空白,有参按削减集削 |
trimLeft / trimRight | 同上 | 只削一侧。削减集不是前缀:"hello"|trimLeft:"he" 得 llo |
replace | |replace:"查找,替换为" | 全部替换,按逗号切且只用前两段——替换文本里含逗号会被截断 |
list | 无参 | 字符串切数组。单字符输入返回空数组;逗号和空格都是分隔符 |
fields | 无参 | 按空白切分 |
count | |count:值 | 字符串算子串出现次数,切片 / map 算等于该值的元素个数 |
index | |index:值 | 字符串按字节取下标,切片取首个匹配元素下标;找不到返回 -1 |
repeat | |repeat:次数 | 重复字符串;负数会 panic,别拿变量直接当次数 |
dump | 无参 | 打印原始结构,调试用 |
thumb | 参数被忽略 | 目录不变、文件名前插 thumb_:/uploads/a/1.jpg → /uploads/a/thumb_1.jpg。与后台「批量缩略图」产出的 1_thumb_200x200_m0.jpg 对不上 |
render | 参数被忽略 | 判据是首字符是不是 <:不是就走 markdown 转 HTML,是就原样返回(连清洗都不做)。返回 HTML,必须配 |safe |
json | 无参 | 序列化成 JSON;序列化失败返回空串 |
priceFormat | |priceFormat:"int" | 入参单位是分,输出元。参数缺省两位小数,int / one / two 控制小数位。入参 0 输出空串 |
dateFormat | |dateFormat:"2006-01-02" | 时间戳转日期,参数是 Go 布局串,缺省 2006-01-02;还认 "diff"(相对秒数)与 "friendly"(3天前)。时间戳为 0 输出空串 |
lazy | |lazy:"data-src" | 把 <img src="a.jpg"> 改成 <img src="" data-src="a.jpg">。只认双引号的 src;输出是 HTML,要配 |safe |
split(覆写) | |split:"分隔符" | 切分;分隔符写 "\\n" 会变成真换行 |
wordwrap(覆写) | |wordwrap:每行词数 | 按词数折行;参数 ≤ 0 时原样返回 |
safe / escape / striptags / truncatechars / divisibleby。全局函数速查 4 个
必须写成函数调用(带括号,不是过滤器):{{ stampToDate(item.CreatedTime, "2006-01-02") }}。
下表是一览;每一项的完整卡片见下方「全局函数」一节。
| 函数 | 用法 | 说明 |
|---|---|---|
stampToDate | {{ stampToDate(ts, "2006-01-02") }} | 时间戳转日期。第二参是 Go 布局串,或 "diff"(第三参定单位 year / month / week / day / hour / minute,默认秒)、"friendly"(3天前;第三参传 zh 开头才出中文)。时间戳为 0 输出空串 |
priceFormat | {{ priceFormat(item.Price, "int") }} | 入参单位是分,输出元;入参 0 输出空串 |
range | {% for i in range(1,5) %} | 生成序列,也支持字符区间 range('a','e') 与步长 range(1,10,2)。两个数字参数时第二个必须是 int |
CustomFunc | {{ CustomFunc().Xxx() }} | 自定义方法挂载点,出厂没有任何可用方法 |
2006、月 01、日 02、时 15、分 04、秒 05。写 Y-m-d 不报错,会把 Y-m-d 原样显示在页面上。前台公开 API 概览
前台除了模板渲染,还能通过 /api 拿数据 —— 这是给无头前端、小程序、独立前端与外部采集用的。全站共 78 个端点:72 个契约端点(写在 server/api/api/ 下的接口定义)+ 6 个不走契约的原始处理器(微信 / 小程序回调、PayPal 返回页、插件反代)。另有 2 个页面级表单路由(/guestbook.html、/captcha)不在 /api 前缀下。
标签和 API 是同一套底层逻辑的两个出口:共用 internal/logic/cmsread 的查询与 internal/logic/cms/view 的视图,所以同一份数据的字段口径完全一致 —— 对照表见下方「API 与模板标签的关系」。
1. 地址怎么拼
站点地址 + /api + 端点路径 例:https://www.example.com/api/archive/list?moduleId=1&limit=10
/api前缀不是写死的:来自server/manifest/config/config.yaml的router.api.prefix,由utility/simple/simple.go:22的RouterPrefix读出,路由注册在server/internal/router/api.go:29。- 契约(路径 / 方法 / 参数名 / 类型)按业务分目录放在
server/api/api/下:cms/ member/ order/ retailer/ pay/ google/ wechat/ weapp/ import/;控制器实现在server/internal/controller/api/。
| 分组 | 契约文件 | 端点数 | 谁要登录 |
|---|---|---|---|
| 内容(文档 / 分类 / 模型 / 单页 / 标签 / 设置 / 互动 / 收藏) | cms/v1/cms.go | 36 | 除白名单外都要 |
| 会员 | member/v1/member.go | 4 | 免登录 |
| Google 登录 | google/v1/google.go | 2 | 免登录 |
| 微信登录 | wechat/v1/wechat.go | 1 | 免登录 |
| 小程序 | weapp/v1/weapp.go | 2 | 免登录 |
| 收银台 | order/v1/order.go | 9 | 要会员登录 |
| 分销员 | retailer/v1/retailer.go | 8 | 要会员登录 |
| 支付回调 | pay/v1/notify.go | 5 | 免登录(平台回调) |
| 导入接口 | import/v1/import.go | 5 | token 鉴权 |
2. 请求要过四道闸,顺序是固定的
全部注册在 server/internal/router/api.go:28-75,顺序即下表从上到下。
| 顺序 | 中间件 | 作用 | 不通过会怎样 |
|---|---|---|---|
| ① | CmsLimiter | 前台请求限流 / IP 封禁 / 内存应急 | HTTP 429 或 403 + 纯文本(不是 JSON 信封) |
| ② | CmsSite | 解析站点上下文(多站点、多语言),不阻断 | — |
| ③ | ApiAuth | 会员登录校验,白名单路径放行 | code:61 + 「请先登录」类提示 |
| ④ | CmsApiOpen | 内容安全「启用API接口」总开关,只包 cms 组 | code:62 + 「接口未启用,请联系管理员开启」 |
这四道之外,还有一层全局中间件在更外层先跑(注册在 cmd/http.go:41-49,对全站生效):Ctx → CORS → InstallGuard(未安装时把请求引到安装页)→ Blacklist(后台 IP 黑名单)→ DemoLimit(演示模式拦 POST)→ PreFilter(请求参数预处理)→ ResponseHandler(统一响应信封)。所以一个前台 API 请求的完整顺序是:
Blacklist → DemoLimit → PreFilter → ResponseHandler
→ CmsLimiter → CmsSite → ApiAuth → CmsApiOpen → 控制器
CmsApiOpen只管内容 API。会员登录注册、支付、微信 / 小程序、Google 登录、导入接口、分销、收银台、插件反代都不受它约束。所以「接口未启用」时,注册登录和下单照样能用,只有内容查询和评论留言会被挡。CmsApiOpen读不到配置时按停用处理(保守失败),此时内容 API 一律拒绝。- 被后台拉黑的 IP、演示模式下的 POST,都在最外层就被拦掉,返回的不是业务错误码。
- 只有
ResponseHandler在最内层包住业务,负责把返回值包成下面第 4 条那个信封。
3. 谁要登录:白名单说了算
免登录路径写在 config.yaml 的 router.api.exceptLogin 里,判定函数是 internal/logic/middleware/init.go:165 的 IsExceptLogin。当前白名单共 30 条:
| 类别 | 路径 |
|---|---|
| 文档 | /archive/detail /archive/list /archive/next /archive/prev /archive/filters /archive/params |
| 分类 / 模型 / 单页 / 标签 | /category/detail /category/list /module/detail /module/list /page/detail /page/list /tag/detail /tag/list /tag/data/list |
| 设置 | /setting/contact /setting/system /setting/index /setting/diy /languages |
| 互动 | /comment/list /comment/publish /comment/praise /captcha /guestbook/fields /guestbook.html |
| 站点数据 | /friendlink/list /nav/list /banner/list /metadata |
白名单是精确匹配,不是前缀匹配:写 /archive/list 就只放行这一个路径。要登录才能调的 cms 端点有 6 个(白名单 30 + 要登录 6 = 内容组 36 个,正好对上):
| 端点 | 用途 |
|---|---|
/archive/publish | 会员投稿 |
/attachment/upload | 附件上传 |
/favorite/list /favorite/check /favorite/add /favorite/delete | 会员收藏(4 个) |
app 声明是 member,当不了后台令牌用。4. 统一返回信封
所有 JSON 端点都走同一个信封(internal/library/response/response.go:41、模型定义在 internal/model/response.go:8):
{
"code": 0,
"message": "操作成功",
"data": { "...": "业务数据" },
"timestamp": 1758600000,
"traceID": "d0bb93048bc5c9164cdee845dcb7f820"
}
code为 0 才是成功。判断成败看code,不要看 HTTP 状态码(多数业务错误 HTTP 仍是 200)。- 出错时数据放在
error字段,不是data(code != 0时data整个不出现)。调试模式下error里是错误堆栈。 message是给人看的中文提示,可能随版本改口径,不要用它做逻辑判断。
常见 code 取值(全部是 GoFrame 框架内置的 gcode 错误码,不是本项目自造的):
| code | 含义 | 什么时候出现 |
|---|---|---|
| 0 | 成功 | — |
| -1 | 失败(业务错误,安全可控) | 绝大多数业务报错,如「文档不存在」「验证码错误」 |
| 51 | 参数校验失败 | required / min 这类校验没过 |
| 60 | 操作失败 | 落库失败等 |
| 61 | 未授权 | 没登录、令牌失效、身份不是会员 |
| 62 | 安全原因 | 「接口未启用,请联系管理员开启」 |
| 65 | 资源不存在 | — |
text/html 或 text/xml;PayPal 同步返回页直接渲染 HTML;微信 / 小程序回调走原始处理器。这些都不套信封。5. 多站点、多语言怎么指定
解析逻辑全在 internal/logic/middleware/cms_site.go:29 的 CmsSite 里,按下面的顺序:
| 顺序 | 依据 | 说明 |
|---|---|---|
| ① | 请求头 Sub-Site-Id,其次 Site-Id | 显式指定优先,命中一个「启用」状态的站点就直接用它,不再看 host |
| ② | host 匹配站点自己的 base_url | 域名站点走这条;带二级目录的站点会同时把 URL 前缀剥掉 |
| ③ | 多语言 | domain 按子站域名、directory 按 URI 首段、same 按 cookie / query |
| ④ | 兜底 | 上面都没命中 → 默认站点(id=1) |
多语言的信号取法(cms_site.go:106):cookie hl 优先,其次 query lang。顺序是先 cookie 后 query,所以用户切过一次语言之后,再带 ?lang= 访问不会生效 —— 这是刻意的,避免语言飘。
6. 限流:会返回非 JSON
判定在 internal/logic/sys/limiter.go:131 的 Check,总开关在后台「安全防护 → 请求限流」。几种拦截形态:
| 触发条件 | HTTP | 响应体 |
|---|---|---|
| 同 IP 访问频率超阈值(首次) | 429 | Too many requests from this IP. |
| 上一条触发后该 IP 被拉黑 | 403 | Your IP is blocked. |
| 内存占用超阈值(应急,持续 5 秒) | 429 | Too many requests. |
| 禁用空 User-Agent,且 UA 为空 | 403 | Forbidden. |
| 静态资源禁空 Referer,且 Referer 为空 | 403 | Forbidden. |
跳过限流的路径(同上文件 :134-149):
- 含
/pay/notify的路径 —— 支付回调绝不能被限流,否则平台重试遇 429 会造成订单状态不同步; - 以
/api/mcp结尾的路径 —— MCP 自带令牌桶; - 以
/static、/uploads、/favicon.ico开头的路径; - 后台配置的「放行路径前缀」;蜘蛛(UA 命中蜘蛛库时)。
router/api.go:31 都写「导入接口在限流内跳过」,但实际判断是 strings.HasSuffix(uri, "/api/import")(limiter.go:149),只匹配路径恰好等于 /api/import 的请求。导入接口的真实路径是 /api/import/archive、/api/import/categories 等,后缀对不上。高频采集场景请在限流配置的「放行路径前缀」里补上 /api/import。7. 通用参数约定
这几个参数在多个端点上反复出现,先集中说清楚,后面端点卡片里就不再重复解释。
| 参数 | 类型 | 说明 |
|---|---|---|
render | bool | 正文是否转成 HTML。不传时按站点「内容设置 → 默认编辑器」判断:编辑器是 markdown 就转,否则不转。传了就以此为准 |
limit | string | 条数。支持 "10" 和 "0,10"(偏移,条数)两种写法。写 0,10 时只取逗号后半段当条数;站点「内容设置」里配了单次最大条数时会截断到上限;显式传了 limit 时下限是 1 |
page | int | 页码,从 1 开始;小于 1 按 1 处理 |
order | string | 排序,形如 "字段 方向" 或 "表.字段 方向",方向只能是 asc / desc(写别的按 desc)。只取第一对,后面的忽略。字段名会自动换算:created_time → created_at、updated_time → updated_at |
child | bool | 分类查询是否包含子分类,缺省 true |
{list: [...], total: N};「业务」类列表(order / retailer 系列)返回 {list: [...], page, size, count} —— count 才是总数。8. 与模板标签的关系
模板标签和公开 API 不是两套实现 —— 它们共用同一批底层查询(internal/logic/cmsread)和同一批视图(internal/logic/cms/view)。完整对照表与「与参考实现的差异」见下方同名章节。
标签侧的完整参数表、坑与示例见本文前半部分的 标签总览;MCP 那套对外接口是独立体系(自带 token 鉴权,与 /api 不共用登录态),见 MCP 接入指南。
端点总览 78 个
共 78 个端点,分 6 组。点端点路径可跳到详细卡片(参数表、返回结构、curl 示例、注意事项都在那里)。
卡片内容来自后台模型「前台公开接口」,改文档只需改后台:分组顺序、组内顺序、参数表、返回结构、示例与注意事项全是后台字段。
参数表怎么读
| 列 | 含义 |
|---|---|
| 参数 | 请求里的键名,大小写敏感(moduleId 不等于 moduleid) |
| 类型 | int64 / string / bool / []int64 等;bool 一律写 true / false,写字符串 "true" 会被当假值 |
| 必填 | 只认「是 / 否」。标「否」不代表可以随便不传 —— 很多端点有「至少给一个」的组合约束,卡片里的注意事项会点名 |
| 说明 | 取值规则、默认值、以及「传了但没生效」这类实情 |
/archive/list 的 type),但控制器可能根本没读它 —— 卡片里会明确标注「传了不改变行为」。前端怎么带登录态
1. 取令牌和传令牌
POST /api/member/login {"username":"...","password":"..."}
→ data.token = "eyJhbGciOi..."
之后的请求带这个头:
Authorization: Bearer eyJhbGciOi...
这条链路上,令牌只认两种位置:Authorization 请求头,或 query 参数 authorization(internal/library/token/token.go:309)。
/api 不读 cookie。「token 请求头 → Authorization 头 → authorization 查询参数 → token Cookie」这套取法只用在页面渲染链路上(token.go:148 的 ParseMemberLoginUser)。前台 fetch('/api/favorite/add') 即使浏览器带着 token cookie,也不会被认成已登录,必须显式加 Authorization 头。这是刻意的:cookie 是浏览器自动带的,把 cookie 认证挂到 API 上等于给这些接口引入 CSRF 面。2. 跨域
站点与后台同域时不需要额外配置;前后端分离(比如小程序、独立前端)时在服务端配 CORS。带 Authorization 头属于「非简单请求」,浏览器会先发 OPTIONS 预检。
3. 调试建议
- 看
code不看 HTTP 状态码(见「统一返回信封」)。 - 出错的细节在
error字段里,message只是给人看的提示。 - 每个响应都带
traceID,排查时拿它去后台「监控 → 接口日志」里对。 - Windows 控制台上中文会显示成乱码,把
curl输出重定向到文件再用编辑器看。
curl 速查
把 BASE 换成你的站点地址即可(下面假定 http://127.0.0.1:8000)。
BASE=http://127.0.0.1:8000
# 1. 文档列表(第一页 10 条)
curl -s "$BASE/api/archive/list?limit=10&page=1"
# 2. 某分类下的文档(含子分类、按发布时间倒序)
curl -s "$BASE/api/archive/list?categoryId=3&order=created_time%20desc&limit=10"
# 3. 文档详情(带正文,正文渲染成 HTML)
curl -s "$BASE/api/archive/detail?id=101&render=true"
# 4. 按 URL 别名取文档
curl -s "$BASE/api/archive/detail?filename=hello-world"
# 5. 带密码的文档
curl -s "$BASE/api/archive/detail?id=101&password=123456"
# 6. 文档自定义字段
curl -s "$BASE/api/archive/params?id=101"
# 7. 筛选条件(价格 + 分类 + 「全部」)
curl -s "$BASE/api/archive/filters?moduleId=2&showPrice=true&showCategory=true&showAll=true"
# 8. 导航树
curl -s "$BASE/api/nav/list?typeId=1&showType=children"
# 9. 验证码
curl -s "$BASE/api/captcha?type=guestbook"
# 10. 提交留言(JSON)
curl -s -X POST "$BASE/api/guestbook.html" \
-H 'Content-Type: application/json' \
-d '{"user_name":"张三","contact":"13800000000","content":"你好"}'
# 11. 会员登录,取出 token
TOKEN=$(curl -s -X POST "$BASE/api/member/login" \
-H 'Content-Type: application/json' \
-d '{"username":"demo","password":"123456"}' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
echo "$TOKEN"
# 12. 带上 token 调需要登录的接口
curl -s "$BASE/api/favorite/list" -H "Authorization: Bearer $TOKEN"
# 13. 收藏 / 取消收藏(toggle)
curl -s -X POST "$BASE/api/favorite/add" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"archive_id":101}'
# 14. 指定站点(多站点场景)
curl -s "$BASE/api/archive/list?limit=5" -H 'Site-Id: 2'
# 15. 指定语言(多语言 single 形态)
curl -s "$BASE/api/archive/list?limit=5&lang=en"
# 16. 页面元数据(给 SPA 用)
curl -s "$BASE/api/metadata?path=/article/hello.html"
# 17. 导入接口(token 走 query)
curl -s "$BASE/api/import/categories?token=你的token&module_id=1"
API 与模板标签的关系
模板标签和公开 API 不是两套实现 —— 它们共用同一批底层查询(internal/logic/cmsread)和同一批视图(internal/logic/cms/view),所以同一份数据的字段口径完全一致。
| 模板标签 | 对应 API | 说明 |
|---|---|---|
archiveDetail | GET /archive/detail | 参数名一致 |
archiveList | GET /archive/list | 参数名一致 |
archiveParams | GET /archive/params | 一致 |
archiveFilters | GET /archive/filters | 一致 |
categoryDetail / categoryList | GET /category/detail / /category/list | 一致 |
moduleDetail / moduleList | GET /module/detail / /module/list | 一致 |
pageDetail / pageList | GET /page/detail / /page/list | 一致 |
tagDetail / tagList / tagDataList | GET /tag/detail / /tag/list / /tag/data/list | 一致 |
commentList | GET /comment/list | 一致 |
guestbookList | — | 只有标签,没有 API(后台数据,不对外) |
navList / linkList / bannerList | GET /nav/list / /friendlink/list / /banner/list | 一致 |
system / contact / diy | GET /setting/system / /setting/contact / /setting/diy | 一致 |
languages | GET /languages | 一致 |
userDetail / userGroupDetail | — | 只有标签,会员信息不走公开 API |
attachment videoStream tdk breadcrumb pagination nextArchive prevArchive jsonLd tr jump set | — | 纯模板能力,没有对应 API |
与参考实现的差异
参考实现(安企 CMS)对应的是 controller/apiTag.go(31 个端点)。本站的差异:
| # | 差异 | 说明 |
|---|---|---|
| 1 | 多 5 个端点 | attachment/upload + favorite/list|check|add|delete(共 5 个),参考实现没有。合计 36 个内容端点 |
| 2 | 收藏按站点隔离,且 /favorite/add 是 toggle 语义 | 参考实现无此能力 |
| 3 | /archive/list 的 type 参数被忽略 | 契约里保留了参考实现的参数名以求兼容,但本站判断「单篇」看的是 id。传 type=page 不会改变行为 |
| 4 | /tag/data/list 与 /archive/list?tag= 的容错不一致 | 前者标签不存在报错,后者返回空列表。这是照参考实现的各自实现路径搬的 |
| 5 | /metadata 是本站补的实现 | 参考实现的公开 API 里没有这一项(它靠服务端渲染) |
| 6 | 导入接口参数名以 module_id 为准 | 对齐参考实现;moduleId 作为兜底保留 |
| 7 | PayPal 同步返回页 | 对齐参考实现 controller/return.go |
API 常见问题
code:62「接口未启用」,但我明明配好了。
内容安全里有个「启用API接口」总开关(api_open)。去后台「安全防护 → 内容安全」把它打开。注意这个开关只管内容 API,注册登录、下单、支付、分销不受影响。
/api/archive/list 里看不到正文?
要显式传 showContent=true。另外密码保护的文档即使传了也不会给正文。
link 为什么和我手拼的不一样?
link 是按当前伪静态方案实时算的。手拼 /article/xxx.html 换个方案就失效 —— 用返回值里的 link。
thumb 是空的?
thumb 取的是 images 图集的第一张(分类 / 标签优先取 logo)。没传图集就是空,不是 bug。
price 是 0,但后台明明填了价。
内容安全开了「登录查看价格」,匿名请求会被置 0。带上会员令牌再调即可。
公开接口一律脱敏(MaskPrivate),这是有意的。
/api/guestbook.html 成功了,但 message 不是我配的成功提示?
先确认后台「留言管理 → 网站留言设置」里确实填了「留言成功提示」,并且已经过了配置缓存(TTL 60 秒)—— 改完配置立刻调接口,可能还是上一份缓存里的文案。正常情况下 message 就是那句配置文案,data 是 {}。
限流按 IP 算,超阈值先 429、再被封就是 403。蜘蛛和 /pay/notify 不受限;导入接口目前会被限流,高频采集请在限流配置的「放行路径前缀」里加 /api/import。
请求头加 Site-Id: <站点ID>(语言子站用 Sub-Site-Id)。不带头时按 host 匹配站点域名,都不匹配就落到默认站点(id=1)。
令牌放 Authorization: Bearer <token> 请求头即可 —— API 本来就不读 cookie。
MCP 接入指南
MCP 对外接口把站点能力开放成 113 个工具、10 个分组。一条 URL + 一个 Token 就能让 Claude Desktop、Cursor、Cherry Studio、WorkBuddy 这类客户端直接读写站内数据。后台「智能与接入设置 → MCP 对外接口」按客户端提供「一键复制配置」。
1. 创建访问 Token
- 进入
/system/→ 智能与接入设置 → MCP 对外接口。 - 新建 Token,按需勾选工具分组(内容、分类与模型、SEO、会员与交易……)。
- 设置调用频率限制:单 Token 的 QPS 与日额度分别可配。
- 复制生成的 Token,只显示一次。
2. 填进客户端配置
{
"mcpServers": {
"tradeadmin": {
"url": "https://your-site.com/mcp",
"headers": {
"Authorization": "Bearer ta_你的Token"
}
}
}
}
3. 验证连通
在客户端里问一句「列出 tradeadmin 有哪些工具」,能返回 113 个工具即接入成功。之后可以直接让 AI 干活:
# 你可以这样说:
帮我把「2026 秋季新品」这个分类下所有文章的标题重写得更口语一些,
改完先别发布,放进待发布。
每次调用都留一行审计日志:谁、什么时候、调了哪个工具、改动了什么。日志在「系统监控 → 日志管理」中查看。
在客户端里建一个智能体,配上「执行策略 + Cron 表达式」,AI 就按点自己跑。每次执行的状态、摘要与完整会话记录都会留档。
运行时插件开发
插件放进 resource/addons/<name>/ 即被自动发现并安装,独立进程运行、崩溃不牵连宿主,语言不限(Go / Node / Python / PHP 均可)。宿主通过 HTTP 反向代理转发请求,并用 HMAC 签名身份头传递调用者身份。
1. 最小可用插件
{
"name": "pay-gateway",
"title": "示例支付网关",
"version": "1.0.0",
"author": "your-name",
"command": ["./pay-gateway"],
"prefix": "/addon/pay-gateway",
"port": 18081,
"hooks": ["pay.notify", "pay.refund"],
"config": {
"app_id": { "type": "text", "label": "应用 ID" },
"app_secret": { "type": "password", "label": "应用密钥" }
}
}
2. 身份头与鉴权
宿主转发请求时会附带以下请求头,插件应校验签名后再处理业务:
| 请求头 | 说明 |
|---|---|
X-Addon-Caller | 调用者标识:system / mcp / agent / frontend |
X-Addon-Ts | Unix 时间戳(秒),超过 300 秒视为过期 |
X-Addon-Sign | HMAC-SHA256(ts + "\n" + body, addon_secret) 的十六进制 |
3. 生命周期
- 发现 —— 扫描
resource/addons/,读取addon.json并登记。 - 安装 / 启动 —— 以后台配置的
command拉起独立进程,等待健康检查通过。 - 运行 —— 宿主按
prefix反向代理到插件端口;插件回调宿主走内部接口。 - 停止 / 卸载 —— 发送终止信号,超时强杀;卸载可选是否保留配置。
skills/<name>/SKILL.md 放进插件目录,安装插件后技能会一并被搜索到并支持热重载。常见坑速查
| 现象 | 原因与解法 |
|---|---|
| 写了参数没反应 | 漏写 with(多数标签直接报 Malformed xxx-tag arguments.);或参数名大小写不对;或该标签根本不读这个参数(pageList / linkList / guestbook 完全不读参数) |
| 参数值变成空 | 字符串没加引号,被当变量解析了 |
| 布尔参数不生效 | 写成了 "true" 字符串,要用关键字 true |
页面上出现字面量 <nil> | pageDetail / userDetail / userGroupDetail 的字段名写错,或「with 打头又不给 name」 |
页面上出现 {xxx} | tdk 的 {字段名} 占位没命中,会原样保留 |
| 整块内容不见了 | 缺 website 渲染上下文(languages 在多语言关闭时也会整块跳过) |
| 分页条不出现 | 没有列表标签写 type="page" 提供分页对象,或只有一页 |
调 /api/* 一律 code:62 | 内容安全「启用API接口」总开关(api_open)没开。注意它只管内容 API,注册登录 / 下单 / 支付 / 分销不受影响 |
| 前端带 cookie 调 API 不算登录 | /api 链路不读 cookie,令牌只认 Authorization 头(或 query 参数 authorization) |
| 列表翻不了页 | 页码写死为 1 的标签:commentList / guestbookList / tagList / tagDataList(后两个分页条画得出来也能点,但点了数据不动);只有 archiveList 真能翻页 |
| 列表条数比 limit 大 | 只有传了 limit 才会应用上限截断 |
| 隐藏内容也出现在列表里 | pageList 用的是「含隐藏页」的查询,不过滤状态 |
| HTML 被显示成源码 | 忘了 |safe(render / lazy 返回 HTML 时必须加) |
时间显示成 Y-m-d | 时间格式串要用 Go 布局串 2006-01-02 |
| 整页变成一行错误文本、HTTP 还是 200 | {# #} 注释里换了行。改成单行注释或块注释 |
| 改了模板前台没变化 | 忘了 template_reload;或静态缓存命中(后台清一次);或改的根本不是这个页面用的模板 |
| 新加的页面 404 | 模板文件名 / 目录名不在「页面与模板对应表」的候选清单里;模板后缀必须是 .html |
| 换了伪静态方案后分页链接不对 | 分页 URL 模板兜底链路拿不到 canonical,要靠 prefix 参数或列表标签提供 |
{% endLanguages %} 报错 | 块标签闭合并写不统一:endLanguages(大写 L)、endattachment(小写)、endjsonLd |
| 结构化数据重复 / 后台配置不生效 | 只要用过 {% jsonLd %} 就会无条件关掉本页的自动注入 |
| 评论 / 留言的 IP、邮箱直接出现在页面上 | 模板标签路径不脱敏(脱敏只在公开 API 里做),模板要自己控制 |
一个完整的列表页模板
把上面这些拼起来,一个带面包屑、列表与分页的列表页长这样。要点是 type="page" 必须写——它既决定按第几页取数,也负责生成分页对象。
{% extends "base.html" %} {% block title %}<title>{% tdk with name="Title" siteName=true %}</title>{% endblock %} {% block container %} {%- breadcrumb crumbs with title="False" %} {%- for item in crumbs %}<a href="{{ item.Link }}">{{ item.Name }}</a>{% endfor %} {%- endbreadcrumb %} {% archiveList archives with type="page" limit="10" %} {% for item in archives %} <a href="{{ item.Link }}">{{ item.Title }}</a> <time>{{ stampToDate(item.CreatedTime, "2006-01-02") }}</time> <p>{{ item.Description|truncatechars:40 }}</p> {% empty %} <li>该分类下暂无内容</li> {% endfor %} {% endarchiveList %} {% include "partial/pagination.html" %} {% endblock %}
分页条本身在 partial/pagination.html 里,写法见 pagination。注意 {% empty %} 是空列表分支,比在外面套一层 {% if %} 干净。
常见问题
模板改动必须调一次 template_reload 才生效(改 css / js 不用)。另外确认「静态缓存」是否命中——本地 HTML 缓存命中时不会重新渲染,后台「运营推广 → 静态缓存」清一次即可。
archiveList 的 categoryId 支持分类 ID 与逗号多值;不传则自动取当前分类。注意 child 默认是 true,分类页的列表默认会带上子分类的文档。
无关。伪静态由后台配置(五种方案全生效,带规则校验与预览),模板里统一用 {{ item.Link }} 这类已经算好的地址即可,不要手拼 URL,切换方案不需要改模板。
不需要。AI 写作、生成 TDK 与摘要、改写、整页翻译都在后台完成,产物写回内容字段;模板照常读字段即可。
用 {% archiveParams %} 遍历;archiveDetail 的 name= 取不到自定义字段。反过来,标签的自定义字段是唯一能用 tagDetail 的 name= 直接取到的。
不需要。发布包启动后打开 /install 填几项即安装完成,程序自动建库、导表、写回配置并加锁,装完不需要重启进程。安装状态记录在 storage/data/installed.lock。