前段时间把说说平台从 0.29.x 升到了 Memos 0.31.0,接口几乎全换了一遍。旧代码直接跑不通,最典型的就是列表接口的
parent参数没了、附件字段从resources改名成attachments。这篇就把升级后实际能用的调用方式逐个拆开讲,示例全部对着线上实例跑过,附接口路径、字段说明、返回结构和错误码,文末还有这次踩到的两个坑——其中一个是防火墙拦的,不是 Memos 的锅。
一、0.31.0 到底变了什么
先说结论:这版不是小修,是把 API 的资源定位方式整个换了一遍。 主要变化有这么几条。
1. 列表接口不再收 parent,改用 CEL filter
旧版查某人的说说,是把作者信息塞在路径里:
1 | # 旧版(0.29.x):作者塞进路径 |
0.31.0 把这个参数拿掉了,换成一个 CEL 表达式风格的 filter 查询参数:
1 | # 0.31.0:作者变成筛选条件 |
这一点很关键——creator 的值是资源名 users/{username},不是数字 ID。这一点和旧版不一样,也是最容易踩的地方。我这边实例升级后实测,users/1 这种数字 ID 已经查不到了,直接返回 user not found。
2. 附件字段更名
Memo.resources 改成了 Memo.attachments,附件的资源名格式和文件路由也跟着变了:
| 项目 | 旧版 | 0.31.0 |
|---|---|---|
| 字段名 | resources | attachments |
| 附件资源名 | resources/{uid} | attachments/{uid} |
| 文件地址 | /api/v1/resources/{uid} 类 | /file/attachments/{uid}/{filename} |
3. 头像地址移出 API
旧版走 /api/v1/users/{id}/avatar,这版统一挪到了文件路由:
1 | 旧:/api/v1/users/{id}/avatar → 实测 404 |
4. 几个版本级别的大改动
除了上面这些实测差异,官方 Release 里还提到几个影响较大的变更,简单列一下:
- Shortcuts 改名 Saved Views:API 从
users/*/shortcuts/*变成users/*/views/*,老客户端必须跟着改 - 评论可见性独立:
CreateMemoComment不再继承主 memo 的可见性,不传就默认PRIVATE - Reaction 移除
contentId:只能从请求路径或资源名里拿 memo 标识 - 管理员不再是隐式协作者:写操作全部走作者权限校验,管理员也改不了别人的 memo
二、调用前提:实例必须是公开访问
这一条得单独拎出来讲,因为它是所有后续问题的根源。
Memos 的实例访问策略和它的公开 URL 是两件事。实例权限如果是「公开访问」,匿名请求才能读到 PUBLIC 的 memo;如果是「私有访问」,不登录就是 authentication required。
我这边实测的状态码对照:
| 场景 | HTTP | 返回体 |
|---|---|---|
| 公开实例 + 匿名读 PUBLIC | 200 | 正常列表 |
| 未认证访问写操作 | 401 | {"code":16,"message":"authentication required"} |
| 带无效 token 读公开 memo | 200 | 正常返回(token 被忽略) |
| 未认证读用户列表 | 401 | {"code":16,...} |
理解这条链路很重要:请求从浏览器出去,先过反向代理,再过防火墙,最后才到 Memos 服务。中间那层防火墙是有自己规则的,后面第五节会讲它怎么坑了我。
三、接口逐个说明
3.1 列出 memo —— 核心接口
这是用得最多的接口,也是这版改动最狠的地方。
请求
1 | GET https://s.dusays.com/api/v1/memos?filter=creator == "users/penn"&pageSize=10 |
| 参数 | 类型 | 说明 |
|---|---|---|
filter | string | CEL 表达式,筛选条件 |
pageSize | int | 每页条数,不传默认 50,非法值也回落成 50 |
pageToken | string | 上一页返回的 nextPageToken |
实测 pageSize 的行为:pageSize=0 返回 50 条,pageSize=1000 也能正常给 1000 条,没有上限保护,所以别拿它当无脑拉全量的工具——真有需要就循环 pageToken。
返回
1 | { |
几个字段值得单独说:
name是 memo 的资源名,格式memos/{uid},后面查详情、改内容全靠它creator是资源名users/{username},不是数字 IDproperty是这版新加的预解析字段,前端要显示「这条有没有链接 / 有没有待办 / 有没有代码块」,不用再自己解析一遍 Markdown,直接读它就行snippet是纯文本摘要,适合做列表预览
3.2 filter 能用什么,不能用什么
这是最容易踩坑的地方。filter 不是随便写都能过的,我把这版实测能用的表达式列一下。
可以正常用的:
1 | # 按作者 |
不能用的(会直接报错):
| 写法 | 结果 |
|---|---|
create_time > "..." | 400 undeclared reference to 'create_time' |
createTime > "..." | 400 undeclared reference to 'createTime' |
state == "NORMAL" | 400 undeclared reference to 'state' |
has(tags) | 400 invalid argument to has() macro |
tags == tags | 400 field "tags" does not support comparison |
注意几个字段名和返回体里的驼峰不一样:返回体是 createTime,但筛选表达式里根本没有时间字段可筛。state、createTime、visibility 之外的字段基本都不可筛。
还有个坑:tag 字段的 exists 宏在某些环境下会被防火墙拦掉(第五节细说),所以标签过滤要换种写法。
3.3 分页:nextPageToken
这版没有 offset 页码,只用游标。逻辑很简单:拿上一页返回的 nextPageToken 当请求参数传回去。
1 | let pageToken = ''; |
两个注意点:
nextPageToken必须原样回传,不要解码也不要拼路径。它是 Base64 变体,里面带==,直接encodeURIComponent就行nextPageToken是空字符串表示到底了,不是null也不是缺字段。判断结束条件要用!pageToken
实测这个游标是稳定的,连着翻两页拿到的 name 不重复、不跳条。
3.4 查单条 memo
1 | GET /api/v1/memos/{uid} |
1 | curl https://s.dusays.com/api/v1/memos/g48Yif2Bd66WvDfuFkeSwe |
返回结构和列表里的一条完全一致。查不到的时候:
1 | {"code":5,"message":"memo not found","details":[]} |
HTTP 状态是 404,注意这版错误响应体统一带 code 字段,而且错误的时候 HTTP 状态码和 body 里的 code 是两套编号,别混:
| 情况 | HTTP | body.code | body.message |
|---|---|---|---|
| 资源不存在 | 404 | 5 | memo not found |
| 路由不存在 | 404 | 5 | Not Found |
| 用户不存在 | 404 | 5 | user not found |
| filter 语法错 | 400 | 3 | invalid filter: ... |
| 未认证 | 401 | 16 | authentication required |
这版是 gRPC 风格的错误码(5 = NOT_FOUND、3 = INVALID_ARGUMENT、16 = UNAUTHENTICATED),不是 HTTP 码直接搬过来。
3.5 用户信息与头像
1 | GET /api/v1/users/{username} |
1 | { |
两个和旧版不一样的地方:
- 用户名精确匹配,大小写敏感。实测
GET /api/v1/users/Penn直接返回 404user not found,只有users/penn能命中,这版用户名不再做大小写归一化了 avatarUrl是相对路径/file/users/penn/avatar,拼接时别忘了加站点域名,而且要写成/file/...而不是/api/v1/...
另外,用户名走 users/{username},数字 ID 这版已经失效——GET /api/v1/users/1 返回 404。老代码里存的用户 ID 全得换成用户名。
想批量拿头像信息的话,GET /api/v1/users 这个用户列表接口是需要认证的,匿名会返回 401。
3.6 memo 的子资源
挂在单条 memo 下的子资源,都用 memo 的资源名做父级:
| 子资源 | 路径 | 返回 |
|---|---|---|
| 附件 | GET /api/v1/memos/{uid}/attachments | {"attachments":[...],"nextPageToken":""} |
| 关联 | GET /api/v1/memos/{uid}/relations | {"relations":[...]} |
| 表情回应 | GET /api/v1/memos/{uid}/reactions | {"reactions":[...],"nextPageToken":""} |
| 评论 | GET /api/v1/memos/{uid}/comments | {"memos":[...],"nextPageToken":""} |
注意评论接口的返回字段叫 memos 而不是 comments——评论本身也是 memo,接口是复用的。想取附件文件本体,地址是 /file/attachments/{uid}/{filename},别再用旧的 resources 路径。
四、页面嵌入代码:完整调用结构
前面讲的是接口本身,这节是把它套到页面上的实际代码。我这边说说广场用的就是这套,跑在 Hexo 页面的 <article class="md-text content"> 里。
4.1 完整结构
1 | <!-- 1. 样式:四个 CSS,顺序有讲究 --> |
加载顺序有硬约束:配置对象 memo 必须在 main.js 之前定义(main.js 顶层直接读 memo.host,配置没到位会抛 ReferenceError),marked.umd.min.js 也要在前面,否则正文 Markdown 渲染不出来。custom.js 和 custom.css 都是空文件占位(实测 custom.js 返回 0 字节),留给你放自己的覆盖逻辑。
4.2 配置项逐个说明
这里有个必须说的事:配置里十个字段,main.js 实际只读了三个。
| 字段 | 是否被 main.js 使用 | 说明 |
|---|---|---|
host | ✅ | Memos 实例地址,末尾 / 会被自动去掉(replace(/\/+$/, '')) |
limit | ✅ | 每页条数,会过 parseInt,NaN 或 < 1 回落成 10 |
creatorId | ✅ | Memos 用户名,拼成 users/{creatorId} 塞进 filter |
domId | ❌ | 容器选择器没被读,实际写死在代码里 |
username | ❌ | 未被读取 |
name | ❌ | 未被读取 |
language | ❌ | 未被读取 |
total | ❌ | 未被读取 |
doubanAPI | ❌ | 未被读取 |
我 grep 过 main.js 全文(158 行),里面出现的配置引用只有 memo.host、memo.limit、memo.creatorId 三处。所以后面那六个字段目前是声明了但不生效——留着无害(将来版本可能用上),但别指望改它们能改变行为。
limit 的默认值行为要留意:limit: '1' 是字符串,会被 parseInt 成数字 1。传 '0'、'abc' 或负数都会回落到 10,不报错。
4.3 main.js 里的请求是怎么拼的
核心就一个 buildListUrl(),把配置和筛选条件拼成最终 URL:
1 | var limit = parseInt(memo.limit, 10); |
分页逻辑是预取下一页:loadFirst() 拿到第一页后,如果 nextPageToken 非空就立刻调 preloadNext() 把第二页拉进 nextDom,用户点「加载更多」时直接渲染已缓存的 nextDom,感知不到延迟。终止条件有两个:返回条数少于 limit,或者 nextPageToken 为空串。
4.4 ⚠️ 这套代码当前有三个问题
写在这里不是要拆台,是这几个坑排查时踩过,别人复用大概率也会撞上。这三个都出在 CDN 上的 penndu@21.0.0,本地仓库没有源码。
一、标签过滤必然 403。 就是第五节坑一那个问题——buildListUrl() 里用的是 tags.exists(t, t == "..."),而 exists( 会被防火墙拦。改成 "' + tagName + '" in tags 即可,两种写法实测返回条数一致。
二、main.js 在 CDN 上是截断的。 实测该文件只有 5912 字节、158 行,且最后一行停在 } else { 后面:
1 | } else { |
文件到这儿就没了——renderMemos() 函数没有定义完。浏览器解析到这儿会抛 SyntaxError: Unexpected end of input,整个 main.js 都不会执行,页面容器 <div id="memos"> 会一直是空的。我抓了线上说说页的 HTML 确认:容器确实是 <div id=memos class=memos></div> 空标签,<span id="total"> 也还是初始值 0,说明渲染没跑起来。
三、memoDom 永远是 null。 全文只有 var memoDom = null; 这一处赋值,之后标签点击时却直接用 memoDom.innerHTML = '':
1 | var memoDom = null; // 全文唯一一次赋值 |
domId 配置项本来应该用来给 memoDom 赋值,但因为 main.js 被截断在 renderMemos() 内部,那段赋值代码没被上传,所以点标签会抛 TypeError。这三个问题里,第二个是根因——补齐 main.js 后,后两个大概率一并解决。
所以现在这份代码贴出来是「存档 + 说明」性质的,真要用建议先核对这几点。
五、两个实打实的坑
前面讲的接口细节都是「怎么用」,这节讲「哪些地方会莫名其妙失败」。
坑一:防火墙把 exists( 拦了
这个坑排查了挺久,起因是我这边标签过滤点了没反应,控制台报错才注意到——返回的根本不是 JSON,是 1Panel 的拦截页:
1 | <div class="text">请求携带恶意参数 已被拦截</div> |
HTTP 状态 403。我把规则缩小到了很具体的一步:
| filter 内容 | 结果 |
|---|---|
tags.exists(t) | 403 被拦 |
exists(t) | 403 被拦 |
foo.exists(t) | 403 被拦 |
tags.exists | 400 正常报错(没到 WAF) |
"读书" in tags | 200 正常 |
size(tags) > 0 | 200 正常 |
content.contains("简报") | 200 正常 |
规律很清楚:只要 exists 后面紧跟左括号 exists(,就被拦。 不带括号的 tags.exists 能过(虽然它本身语法也不对,会返回 400)。而且这层防护拦的是请求参数,跟路径无关——我试过 ?x=exists(t) 一样 403。
所以标签过滤要改成 in 写法:
1 | // 会被 403 |
两种写法实测返回条数一致,语义也等价。如果你自己也是套了防护面板,自查一下有没有把 CEL 表达式里的关键字误伤。 好消息是 in 写法本身语法更短,也不挑环境,比 exists 更值得当默认写法。
坑二:has_link 筛不出「没有链接」的 memo
这个不算 bug,算个语义陷阱。
1 | # 基线:前 100 条里 hasLink 都是 false |
基线里明明全是 hasLink=false,筛 false 反而一条都不出。翻这 5 条命中的记录,property.hasLink 字段确实都是 true,内容也确实带链接。所以这个字段是能筛的,但 false 这个取值的行为跟直觉对不上,用之前先拿小样本验一下,别直接拿去做生产筛选。
顺带一个:页面大小没有上限
pageSize=1000 实测真的返回 1000 条,没有服务端截断。这个在自用场景无害,但如果你的调用方不可信,一个 pageSize=100000 就能把内存打满。自己在前面兜一层就行。
六、代码错误怎么兜
这一节把前面的错误码整理成可直接用的处理逻辑。核心原则:先判 HTTP 状态,再判 body 里的 code。
通用错误处理
1 | async function callMemos(path, params = {}) { |
上面 !contentType.includes('application/json') 那个判断是实战里加的——不加的话,await res.json() 会直接抛一个语法错误异常,信息全丢了,根本看不出是被防火墙拦的。
按状态码分支
1 | try { |
关于 filter 语法错(400)
filter 报 400 时,错误消息里会带 CEL 编译器的原始报错,能直接看出错在哪。实测两种典型错法:
1 | # 逻辑用错:单 & 不是「与」,是位与 |
1 | {"code":3,"message":"invalid filter: failed to compile filter: |
1 | # 字段名用了返回体里的驼峰 |
1 | {"code":3,"message":"invalid filter: ... undeclared reference to 'createTime'"} |
注意看这个报错——它把整个 filter 表达式原样回显在消息里。如果你的 filter 里带了敏感信息,注意一下。
七、完整可用示例
把前面几节串起来,一个能直接跑的完整抓取脚本:
1 | // 抓取某人全部公开 memo,支持标签筛选 |
跑一下就知道条数对不对:
1 | const list = await fetchMemos({ |
分页也顺手验一下,游标翻到底不重不漏:
1 | // 实测:23 页 / 2226 条 / 去重后仍是 2226,无重复 |
八、迁移检查清单
从旧版升过来,按这个顺序过一遍基本不会漏:
parent全部换成filter,作者写成users/{username},别用数字 IDresources全部换成attachments,文件地址换成/file/attachments/{uid}/{filename}- 头像地址换成
/file/users/{username}/avatar - 筛选表达式里的
exists(换成"标签" in tags,尤其自己套了防护面板的 - 用户名改成精确匹配,别再依赖大小写不敏感
shortcuts相关调用换成views- 评论请求显式指定可见性,别依赖继承
- 错误处理同时判 HTTP 状态和 body.code
九、写在最后
0.31.0 这版改动集中在一个点上:把所有「定位资源」的方式统一到资源名 + CEL filter 上了。 users/penn、memos/g48Yif2Bd66WvDfuFkeSwe、attachments/{uid} 这类资源名成了唯一入口,字段名也统一成了驼峰。
代价是迁移必须跟着改,好处是筛选能力上了一个台阶——过去只能拉全量再前端过滤,现在可以直接在服务端按作者、标签、可见性、正文内容筛。property 字段的预解析也省掉了前端一层 Markdown 解析。
前面第二节那个 pageSize 没上限、第五节那两个坑,都是实测撞出来的,不是文档里写的。0.31.0 的 API 文档还在跟着版本走,示例里 parent 这类老写法也能搜到不少,所以别完全照抄官方示例——跑一遍才知道合不合你的部署。
本文所有接口路径、字段名、返回结构、错误码和请求结果,都是对着线上实例逐个实测的(curl 直接打 s.dusays.com)。示例代码里的筛选表达式也都在真实环境下验证过可用性。
第四节那段嵌入代码是我自己说说广场在用的,贴出来时也顺手核了一遍——结果发现三个问题,其中 main.js 在 CDN 上是被截断的(5912 字节,renderMemos() 写了一半),这大概也解释了为什么线上说说页现在是空的。补齐这个文件之后,标签过滤和 memoDom 相关的两个问题应该能一起解决。
说说平台搬到了 Memos,接口细节和踩坑记录都写在这了,欢迎留言交流。
