文档索引

投票机器人接入说明

面向站外开发者。读完本文即可实现一个投票机器人,无需阅读本站源码。

本文是投票接口的唯一对外口径。字段、长度上限、限频数值、错误码均照实抄录, 若与源码不符以源码为准(但也请提 issue —— 那就是本文过期了)。

0. 一句话说清

没有专用的「机器人接口」。 机器人就是一个普通的 core+ 账号, 走和对人完全一样的 HTTP 接口。你需要的只有四件事:

  1. 一个 core+ 账号(§2)—— 注册出来是 user,需要提权一次
  2. 一个 POST 建投票(§4)—— 拿回 9 位投票 ID
  3. 一个 POST 投票(§5)
  4. 想收场就锁定或删除(§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 → 退避到下一个窗口。

本页内容来自仓库 docs/bot/vote-bot.md·在 GitHub 上查看