投票机器人接入说明
面向站外开发者。读完本文即可实现一个投票机器人,无需阅读本站源码。
本文是投票接口的唯一对外口径。字段、长度上限、限频数值、错误码均照实抄录, 若与源码不符以源码为准(但也请提 issue —— 那就是本文过期了)。
0. 一句话说清
没有专用的「机器人接口」。 机器人就是一个普通的 core+ 账号, 走和对人完全一样的 HTTP 接口。你需要的只有四件事:
- 一个 core+ 账号(§2)—— 注册出来是
user,需要提权一次 - 一个 POST 建投票(§4)—— 拿回 9 位投票 ID
- 一个 POST 投票(§5)
- 想收场就锁定或删除(§7)—— 两条都在接口上,不需要管理员帮忙
无需申请 token、无需白名单、无需站长开任何开关。
⚠️ 投票是公开内容。 建出来就在站内可见(投票页
/vote/<id>), 且会成为[@9位ID]可嵌入的对象。这是会对真人产生后果的接口,见 §10。
📌 本文自包含。账号准备(注册 / 提权)与鉴权(cookie)是全部机器人共用的前置步骤, 三份完整口径见
docs/bot/chat-bot.md§2 与 §3 —— 本文 §2 只给最小可跑的部分。 时间戳的口径全站同一个,见 §3。
1. 能力边界(先读这段)
| 范围 | 能读 | 能写 | 说明 |
|---|---|---|---|
| 建投票 | —— | ✅ | POST /api/votes,需 core+ |
| 投一票 | —— | ✅ | POST /api/votes/:id/vote,一人一票、投了不能改 |
| 锁定 / 解锁 | —— | ✅ | PATCH /api/votes/:id,仅创建者 |
| 删除 | —— | ✅ | DELETE /api/votes/:id,仅创建者,软删 |
| 读某个投票的结果 | ✅ | ❌ | GET /api/votes/:id,需 core+ 登录 |
| 列自己的投票 | ✅ | ❌ | GET /api/votes —— 只列自己创建的 |
| 看别人的投票列表 | ❌ | ❌ | 没有这类接口;只有站长在管理端能翻 |
| 改投票的标题或选项 | ❌ | ❌ | 建了就改不了(§4.3)—— 建之前想清楚 |
| 看「谁投了哪个选项」 | ✅ | ❌ | 只有创建者在网页上看得到(创建者视角的详情页),接口不下发投票人名单 |
机器人的权限边界是三条:core+ 角色、登录状态、是否被禁言。
⚠️ 机器人享受不到任何豁免:它同样会被禁言(被禁言期间发消息类接口返回
403),也同样受限频与「每人最多 100 个投票」的约束(§9)。📌 一处与别处不同的细节:投票域的接口都不判禁言(建投票、投票、锁定、删除都不判) —— 这是刻意的,与
POST /api/votes的历史口径一致。禁言管的是「发言」, 而投票是创建者对自己那份数据的处置。真正挡在前面的是限频(§9)。
2. 准备账号与鉴权
投票接口全部需要 core+ 且登录。注册出来的账号是 user,要提权一次:
- 注册时带有效邀请码 → 直接是 core(推荐,全自动);
- 让站长在服务器上执行:
npm run cli -- promote-core mybot
完整流程(注册字段、人机验证、错误码)见 docs/bot/chat-bot.md §2。登录换 cookie:
POST /api/auth/login
Content-Type: application/json
{ "username": "mybot", "password": "********" }
响应会 Set-Cookie: raricy_session=<JWT>(HttpOnly、SameSite=Lax、30 天)。
之后每个请求带上 Cookie: raricy_session=<JWT> 即可。
⚠️ 写请求会校验来源,跨站会被拦成
403 跨源请求被拒绝 (CSRF)。 用任何 HTTP 客户端都没问题,但别在浏览器页面里用fetch跨站调它。 非浏览器客户端本来就不发Origin/Referer,天然通过 —— 不要自作主张伪造一个。
会话失效(改密、被强制下线、被禁言 / 重置密码)后所有接口一律回
401 请先登录。 把401当成「重新登录」的信号,而不是重试。⚠️ 被降权(core → user)不属于会话失效 —— 会话仍有效,只是投票接口改回
403 需要核心用户权限。重登解决不了这个(那是档位问题,不是登录问题)。
3. 响应信封与时间戳
站内接口(/api/...)共用同一信封:
{ "code": 200, "message": "ok", ...其他数据字段 }
出错时 code 与 HTTP 状态码一致,且只有 code 和 message。
站内接口一律 Cache-Control: no-store。
时间戳是 ISO 8601 字符串带 Z 后缀,但那个 Z 是假的 ——
本站全库时间戳的语义是「UTC+8 墙上时间,贴 Z 标签」:
// ✅ 原样展示:截断即可得到北京时间墙上时间
const wall = created_at.slice(0, 19).replace('T', ' '); // "2026-09-19 20:31:05"
// ✅ 想得到真实瞬间(做时间差)
const realMs = new Date(created_at).getTime() - 8 * 3600 * 1000;
全站的坑是同一个,正确与错误的完整写法见
docs/bot/comment-bot.md§5。
4. 建投票
POST /api/votes
Cookie: raricy_session=<JWT>
Content-Type: application/json
{ "title": "今晚吃什么?", "options": ["火锅", "烧烤", "随便"] }
4.1 请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
title |
string | 是 | 标题,trim() 之后 1–200 字符 |
options |
string[] | 是 | 选项,2–10 个;每个 trim() 之后 1–200 字符 |
⚠️
options必须是数组。传字符串会400 请提供选项列表—— 它不会被按逗号切开,也不接受[{ "label": "…" }]这种对象数组。⚠️ 数组里的非字符串项会被静默丢掉(
["甲", 42, null]等价于["甲"]), 于是两项的投票可能因为这一条变成一项 →400 选项数量必须在2-10个之间。 报错说的是「数量」,但根因是类型 —— 别只盯着数量改。
4.2 成功响应
{ "code": 200, "message": "success", "data": { "id": "aB3dE7gHi" } }
✅
id是 9 位 base62(大小写字母 + 数字)的字符串,不是自增数字。 建完请把它存下来 —— 投票、锁定、删除都要靠它。 站内地址是https://raricy.com/vote/<id>。
4.3 建之前想清楚:投票改不了
本站没有「改投票」的接口,也没有删选项的接口。 建成什么样就是什么样, 唯一的收场手段是锁定(停止投票)或删除(§7)。
⚠️ 也不要拿「删了重建」当编辑用:软删的痕迹在站内与审计侧都看得到, 且每次重建都从你的创建额度里扣一笔(§9 的 100 个上限按未软删的计数, 但限频按请求计数)。
4.4 会被拒绝的情形
| 情形 | 返回 |
|---|---|
| 未登录 / Cookie 失效 | 401 请先登录 |
| 账号不是 core+ | 403 需要核心用户权限 |
| 请求体不是 JSON | 400 请求格式错误 |
options 不是数组 / 缺字段 |
400 请提供选项列表 |
| 标题长度不在 1–200 | 400 标题长度必须在1-200字符之间 |
| 选项数量不在 2–10 | 400 选项数量必须在2-10个之间 |
| 某个选项长度不在 1–200 | 400 每个选项长度必须在1-200字符之间 |
| 已有的投票已达 100 个 | 400 每个用户最多创建 100 个投票 |
| 触发限频 | 429 创建频率过高,请稍后再试 |
📌 校验顺序:标题 → 选项数量 → 逐项长度 → 限频 → 总数上限。 一次只报第一条。
⚠️ 选项数量与长度都按
trim()之后算,但标题与选项按原样入库 (" 甲 "存进去就是" 甲 ")。想干净就自己先 trim 再发。
5. 投票
POST /api/votes/aB3dE7gHi/vote
Cookie: raricy_session=<JWT>
Content-Type: application/json
{ "optionId": 42 }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
optionId |
number | string | 是 | 选项的数字 id,从 §6 的详情里取 |
⚠️
optionId是数字 id,不是选项的下标(0/1/2),也不是选项文案。 传0不会「投第一项」,只会400 选项不存在(除非真有个 id 为 0 的选项)。
成功:
{ "code": 200, "message": "投票成功" }
⚠️ 成功响应里没有票数,也没有你投的是哪一项 —— 想拿结果请再 GET 一次详情(§6)。
5.1 一人一票,投了不能改
| 情形 | 返回 |
|---|---|
| 未登录 / 非 core+ | 401 / 403 需要核心用户权限 |
optionId 不是整数 |
400 请选择一个选项 |
| 投票不存在 / 已被软删 | 404 投票不存在 |
| 投票已锁定 | 400 投票已锁定,无法投票 |
| 选项不属于这个投票 | 400 选项不存在 |
| 你已经投过了 | 400 您已经投过票了 |
| 触发限频 | 429 投票频率过高,请稍后再试 |
⚠️ 没有「改票」「撤票」接口。 投出去就是投出去了 —— 重复投只会拿到
400 您已经投过票了。⚠️ 别把「已投过」当错误重试:它是终态。机器人应当先 GET 详情看
user_voted是不是null(§6),再决定要不要投 —— 这也顺便省下了一次配额。
📌 限频在参数校验之后(顺序不可改):投不存在的投票 / 重复投票 不消耗你的 30 次/时。所以「先探一次再投」不会把自己锁死。
6. 读投票
GET /api/votes/aB3dE7gHi
Cookie: raricy_session=<JWT>
需 core+ 登录(未登录 401,非 core 403,不存在 404 投票不存在)。
{
"code": 200,
"message": "ok",
"data": {
"id": "aB3dE7gHi",
"title": "今晚吃什么?",
"author_id": "u_xxx",
"author_name": "alice",
"is_creator": false,
"is_locked": false,
"created_at": "2026-09-19T20:31:05.000Z",
"total_votes": 12,
"user_voted": 42,
"options": [
{ "id": 42, "label": "火锅", "count": 7, "percentage": 58.3 },
{ "id": 43, "label": "烧烤", "count": 5, "percentage": 41.7 }
]
}
}
| 字段 | 说明 |
|---|---|
is_creator |
你是不是创建者(也是「能不能锁定/删除」的判据) |
is_locked |
锁定的投票仍可读结果,只是不能再投 |
total_votes |
总票数 |
user_voted |
你投的 optionId;没投过是 null |
options[].percentage |
一位小数;总票数为 0 时全部是 0 |
options[] |
按站点展示顺序(即建投票时的数组顺序) |
📌 投票人名单不下发。 网页上只有创建者看得到「谁投了哪个选项」, 那条数据走的是页面自身的服务端渲染,没有对应的接口。
6.1 列自己的投票
GET /api/votes
Cookie: raricy_session=<JWT>
只列你自己创建的(ignore=false,最新在前):
{
"code": 200,
"message": "ok",
"votes": [
{
"id": "aB3dE7gHi",
"title": "今晚吃什么?",
"author_id": "u_xxx",
"author_name": "alice",
"is_locked": false,
"created_at": "2026-09-19T20:31:05.000Z",
"option_count": 3,
"total_votes": 12
}
]
}
⚠️ 注意字段名的不对称:列表里是
votes[]+option_count, 详情里是data{}+options[]。这一域没有分页参数(列表是你自己的全部投票, 上限 100 个)。
7. 锁定 / 解锁 / 删除(仅创建者)
📌 这三件事在 2026-09 之前只有网页上点得到,现在有了接口 —— 机器人可以自己收场,不需要找站长。
7.1 锁定 / 解锁
PATCH /api/votes/aB3dE7gHi
Cookie: raricy_session=<JWT>
Content-Type: application/json
{ "locked": true }
{ "code": 200, "message": "已锁定", "id": "aB3dE7gHi", "is_locked": true }
| 情形 | 返回 |
|---|---|
| 未登录 / 非 core+ | 401 / 403 需要核心用户权限 |
locked 不是布尔 |
400 locked 必须是 true 或 false |
| 投票不存在 / 已被软删 | 404 投票不存在 |
| 你不是创建者 | 403 无权管理该投票 |
⚠️
locked只认真布尔:"true"、1、0、null一律400。 本站不做 truthy 解释 —— 静默把locked: 0当成true会把投票锁上, 而那是不报错的错账。✅ 幂等:重复锁同一个状态不报错,也不消耗额外配额(这一域没有锁定限频)。
锁定 = 停止投票,不等于隐藏。 所有人仍看得到标题、选项与当前结果, 只是没人能再投 —— 这正是它和「删除」的分工。
7.2 删除
DELETE /api/votes/aB3dE7gHi
Cookie: raricy_session=<JWT>
{ "code": 200, "message": "投票已删除", "id": "aB3dE7gHi", "redirect": "/vote" }
| 情形 | 返回 |
|---|---|
| 未登录 / 非 core+ | 401 / 403 需要核心用户权限 |
| 投票不存在 / 已删过 | 404 投票不存在 |
| 你不是创建者 | 403 无权删除该投票 |
⚠️ 这是软删除:站点永不物理删除。删掉之后这个 id 一律
404, 但它不是「可以反复清理重建」的机制 —— 票还在库里(见下), 复用同一个标题反复删建,在站内与审计侧都看得到痕迹。
⚠️ 已经投出去的票不会跟着消失。 软删只把它从对外视图中拿掉, 投票记录(谁投了哪个选项)保留在库里 —— 这是有意的:票是别人做的动作, 不该因为创建者反悔而被抹掉。所以「删了重发一次,让大家重新投」拿不到干净的重来。
8. 在正文里引用投票([@9位ID])
站内四处正文支持内容引用语法(博客正文 / 云剪贴板正文 / 评论区 / 讨论区), 其中会把 9 位 ID 渲染成投票嵌入的只有前两处:
| 写在哪 | 效果 |
|---|---|
| 博客正文 | 渲染成投票卡片(可点即投) |
| 云剪贴板正文 | 同上(剪贴板详情页也是完整渲染) |
| 评论正文 | 原样显示 [@aB3dE7gHi](不展开)—— 刻意的:投票是交互组件,塞进楼中楼里没有意义 |
| 讨论消息 | 原样显示(不展开),同上 |
📌 所以「发个投票让大家投」的机器人要么把人引到
/vote/<id>, 要么把投票引用写进博客正文或剪贴板正文。写在评论或讨论里只会显示成一串记号。完整语法(代码块内不展开、一次最多展开几个等)见
docs/guide/内容引用语法指南.md。
9. 限频
| 规则 | 额度 | 适用 |
|---|---|---|
| 建投票 | 10 次 / 小时 | POST /api/votes(按用户计) |
| 投票 | 30 次 / 小时 | POST /api/votes/:id/vote(按用户计) |
| 读投票 / 列投票 | 无限频 | §6 两条 |
| 锁定 / 解锁 / 删除 | 无限频 | §7 三条 |
| 每人可创建的投票总数 | 100 个 | 数「自己创建的、未软删的」,不是 RULES |
超限一律 429。
⚠️ 投票那条 30 次/时是「按用户」而不是「按投票」 —— 全站共用这一个预算。 一个「看到投票就投」的机器人投满 30 次后,接下来一小时里所有投票都投不了。 请只投明确该投的那些。
⚠️ 两条额度都只按次数计,与「投给了谁」无关;读接口不消耗任何额度, 但别拿读当轮询(无意义且会给站点添负担)。
限频计数存在服务端(进程内计数桶,定期落盘快照),重启不会清零 —— 别指望靠重启洗掉自己的用量。但它也不是持久化账本,不要拿它做用量统计。
10. 礼仪与约定
- 投票是公开内容,且会打扰真人。 建投票 = 在站内挂出一个议题; 它可能被别人的博客、评论引用。建之前自问一句:这个值得占一个位置吗?
- 别把投票当存储用。 「每人最多 100 个」与 10 次/时不是给你做数据收集的额度。
- 一人一票是硬规则。 别用多个账号给自己刷票 —— 那是站内最容易被看出来的模式, 且站长能用处理普通用户的手段(禁言、封号)处理你的机器人。
- 锁定要说明理由。 你锁了一个有人正在参与的投票,那些人只会看到「已锁定」。 如果机器人有对外渠道,先说一声。
- 删除是不可逆的对外动作。 引用过这个投票的博客会显示成失效的嵌入。 投票「结束了」通常该用锁定,而不是删除。
11. 排错速查
| 现象 | 多半是 |
|---|---|
400 请提供选项列表 |
options 不是数组(传了字符串 / 对象数组) |
400 选项数量必须在2-10个之间 |
真的少于 2 个,或数组里混了非字符串项被丢掉了(§4.1) |
400 每个选项长度必须在1-200字符之间 |
某个选项 trim 后为空、或超过 200 字符 |
400 每个用户最多创建 100 个投票 |
未软删的投票满了 —— 删掉不用的再建,或改建别的形式 |
429 创建频率过高 |
10 次/时用完了,等下一个小时 |
400 您已经投过票了 |
终态,别再重试;要确认先 GET 详情看 user_voted |
400 选项不存在 |
optionId 传成了下标(0/1/2)或选项文案 |
400 投票已锁定,无法投票 |
创建者锁了它 —— 这是正常状态,不是故障 |
403 无权管理该投票 / 403 无权删除该投票 |
不是创建者。别人建的投票你只能投 |
404 投票不存在 |
id 抄错,或已被创建者软删(两者同形) |
| 投票引用在评论里不展开 | 刻意的:只有博客正文渲染投票嵌入(§8) |
| 时间整体差 8 小时 | 假的 Z,见 §3 |
12. 最小可用流程
// 0. 登录(见 chat-bot.md §3),拿到 raricy_session(此处存进 cookie 变量)
// 1. 建投票
const created = await (await fetch('https://raricy.com/api/votes', {
method: 'POST',
headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ title: '今晚吃什么?', options: ['火锅', '烧烤', '随便'] }),
})).json();
const voteId = created.data.id; // 9 位字符串,存下来
// 2. 投之前先看自己投没投过(省一次配额,也避免必然失败的请求)
const detail = await (await fetch(`https://raricy.com/api/votes/${voteId}`, { headers: { cookie } })).json();
if (detail.data.user_voted === null) {
const optionId = detail.data.options[0].id; // 注意:是 id,不是下标
const res = await fetch(`https://raricy.com/api/votes/${voteId}/vote`, {
method: 'POST',
headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ optionId }),
});
console.log('投票结果', res.status);
}
// 3. 收场:二选一
// 锁定(保留结果、停止投票)—— 推荐
await fetch(`https://raricy.com/api/votes/${voteId}`, {
method: 'PATCH',
headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ locked: true }),
});
// 删除(对外消失,票留在库里)—— 慎用
// await fetch(`https://raricy.com/api/votes/${voteId}`, { method: 'DELETE', headers: { cookie } });
断线 / 401 → 重新登录(docs/bot/chat-bot.md §3)。429 → 退避到下一个窗口。