文档中心

开发文档

35 个模板标签、17 个自定义过滤器(另有 2 个覆写)与 4 个全局函数,逐条给出参数、示例与常见坑;78 个前台公开 API 端点,逐条给出鉴权要求、参数表、返回结构与 curl 示例。分类 / 模型 / 内容三层结构在模板里各司其职;前台 SEO 由后台配置在渲染后自动注入,模板不需要也不应手写 JSON-LD。

模板语法概览

TradeAdmin 的前台由 pongo2(Django 风格)模板引擎驱动。模板就是一份普通的 HTML,标签写在 HTML 里,渲染时被替换成数据。文件名后缀必须是 .html;变量与字段严格区分大小写,字段首字母大写——{{ archive.Title }} 对,{{ archive.title }} 取不到。

1. 模板里能用的四类东西

类别数量写法
业务标签34 个{% archiveList ... %}
覆写的 set1 个{% 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/<模板名>/
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 兜底)
404errors/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 时原样返回
出厂的 default 主题一个自定义过滤器都没用,它只用了 pongo2 内置的 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() }}自定义方法挂载点,出厂没有任何可用方法
时间格式串一律是 Go 布局串:年 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 前缀下。

本文按源码实际行为写。「参数收下了但没生效」「返回的 message 其实是固定的」这类情况会当场点名,不粉饰。

标签和 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.go36除白名单外都要
会员member/v1/member.go4免登录
Google 登录google/v1/google.go2免登录
微信登录wechat/v1/wechat.go1免登录
小程序weapp/v1/weapp.go2免登录
收银台order/v1/order.go9要会员登录
分销员retailer/v1/retailer.go8要会员登录
支付回调pay/v1/notify.go5免登录(平台回调)
导入接口import/v1/import.go5token 鉴权

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 个)
登录校验除了「有没有令牌」,还多一道身份检查 —— 必须是前台会员。拿后台管理员的 token 来调会员 API 会被拒(「会员身份校验失败,请重新登录!」);反之,前台会员 token 里的 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资源不存在—
不是 JSON 的响应:支付回调按平台要求返回 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 访问频率超阈值(首次)429Too many requests from this IP.
上一条触发后该 IP 被拉黑403Your IP is blocked.
内存占用超阈值(应急,持续 5 秒)429Too many requests.
禁用空 User-Agent,且 UA 为空403Forbidden.
静态资源禁空 Referer,且 Referer 为空403Forbidden.

跳过限流的路径(同上文件 :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. 通用参数约定

这几个参数在多个端点上反复出现,先集中说清楚,后面端点卡片里就不再重复解释。

参数类型说明
renderbool正文是否转成 HTML。不传时按站点「内容设置 → 默认编辑器」判断:编辑器是 markdown 就转,否则不转。传了就以此为准
limitstring条数。支持 "10" 和 "0,10"(偏移,条数)两种写法。写 0,10 时只取逗号后半段当条数;站点「内容设置」里配了单次最大条数时会截断到上限;显式传了 limit 时下限是 1
pageint页码,从 1 开始;小于 1 按 1 处理
orderstring排序,形如 "字段 方向" 或 "表.字段 方向",方向只能是 asc / desc(写别的按 desc)。只取第一对,后面的忽略。字段名会自动换算:created_time → created_at、updated_time → updated_at
childbool分类查询是否包含子分类,缺省 true
分页返回的两种形状不一样,对接时注意:「内容」类列表(archive / tag / comment / tag-data / favorite)返回 {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说明
archiveDetailGET /archive/detail参数名一致
archiveListGET /archive/list参数名一致
archiveParamsGET /archive/params一致
archiveFiltersGET /archive/filters一致
categoryDetail / categoryListGET /category/detail / /category/list一致
moduleDetail / moduleListGET /module/detail / /module/list一致
pageDetail / pageListGET /page/detail / /page/list一致
tagDetail / tagList / tagDataListGET /tag/detail / /tag/list / /tag/data/list一致
commentListGET /comment/list一致
guestbookList—只有标签,没有 API(后台数据,不对外)
navList / linkList / bannerListGET /nav/list / /friendlink/list / /banner/list一致
system / contact / diyGET /setting/system / /setting/contact / /setting/diy一致
languagesGET /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 作为兜底保留
7PayPal 同步返回页对齐参考实现 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。带上会员令牌再调即可。

评论列表里拿不到评论者邮箱 / IP?

公开接口一律脱敏(MaskPrivate),这是有意的。

/api/guestbook.html 成功了,但 message 不是我配的成功提示?

先确认后台「留言管理 → 网站留言设置」里确实填了「留言成功提示」,并且已经过了配置缓存(TTL 60 秒)—— 改完配置立刻调接口,可能还是上一份缓存里的文案。正常情况下 message 就是那句配置文案,data 是 {}。

调用频率高就 429 或 403 了。

限流按 IP 算,超阈值先 429、再被封就是 403。蜘蛛和 /pay/notify 不受限;导入接口目前会被限流,高频采集请在限流配置的「放行路径前缀」里加 /api/import。

多站点下我拿到的是主站数据。

请求头加 Site-Id: <站点ID>(语言子站用 Sub-Site-Id)。不带头时按 host 匹配站点域名,都不匹配就落到默认站点(id=1)。

小程序 / 独立前端不能带 cookie,登录态怎么维持?

令牌放 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. 填进客户端配置

claude_desktop_config.json / mcp.json
{
  "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. 最小可用插件

addon.json
{
  "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-TsUnix 时间戳(秒),超过 300 秒视为过期
X-Addon-SignHMAC-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" 必须写——它既决定按第几页取数,也负责生成分页对象。

{模块别名}/list.html
{% 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 能力需要在模板里做什么?

不需要。AI 写作、生成 TDK 与摘要、改写、整页翻译都在后台完成,产物写回内容字段;模板照常读字段即可。

能拿文档的自定义字段吗?

用 {% archiveParams %} 遍历;archiveDetail 的 name= 取不到自定义字段。反过来,标签的自定义字段是唯一能用 tagDetail 的 name= 直接取到的。

装完站之后还需要改配置文件吗?

不需要。发布包启动后打开 /install 填几项即安装完成,程序自动建库、导表、写回配置并加锁,装完不需要重启进程。安装状态记录在 storage/data/installed.lock。