文档索引

鱼干机器人接入说明(无状态接口)

面向站外开发者。读完本文即可写一个会转账的机器人,无需阅读本站源码。

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

📌 想把「收 / 付 / 对账」整套拼起来(开银行、做托管), 直接看 docs/bot/fish-bank-example.md —— 那是本文这些接口的一份可运行实现。

0. 一句话说清

这条路径不需要先登录。 把 username + password 放进请求体, 一次 POST 就完成一笔转账(或查余额 / 查流水)—— 不签发 cookie、不要求先调 /api/auth/login,因此运行环境不需要保存会话。

curl -X POST https://raricy.com/api/fish/market/transfer \
  -H 'Content-Type: application/json' \
  -d '{
    "username": "mybot",
    "password": "********",
    "to_username": "alice",
    "amount": 1
  }'

⚠️ 先读 §1 的「重试 = 再转一笔」。 这是本文档里最容易让机器人亏钱的一条。

1. 能力边界与三条硬约束

能做什么 接口 只读凭据能做吗
从自己的账号转出鱼干给任意用户(零手续费) POST /api/fish/market/transfer ❌ 403
查自己的余额 POST /api/fish/market/balance ✅
查自己的流水 POST /api/fish/market/transactions ✅

(收银台 /fish/pay 是用户自己在站内付款,不是机器人接口,故不在此表。)

三条硬约束:

  1. 只能动自己的账号。 凭据是谁的,就只能以谁的名义转账/查询 —— 没有「管理员代操作」这类接口。
  2. 重试 = 再转一笔。 服务端不做请求去重:每一次成功返回 200 的调用都是 一笔新交易(幂等键带随机后缀,见 §6)。网络超时不代表没成交 —— 请先查流水(transfer_all)确认,再决定要不要重发。别无脑重试。 (带着 §6.1 那个 idempotency_key 时另说:同键重发是安全的,不必先查流水。)
  3. 被禁言的账号一律 403,机器人没有豁免。出问题时站长能像处理普通用户一样处理它 —— 包括让它手里的只读凭据立即失效。

2. 鉴权

2.1 三种方式

方式 怎么带 适用 能转账吗
只读凭据(推荐) 头 Authorization: Bearer <令牌> 站外机器人 / 银行:长期运行、要轮询 ❌ 不能
会话(浏览器) Cookie raricy_session 站内页面;脚本若已 POST /api/auth/login 也可以 ✅
密码(本文档) body 里的 username + password 站外脚本:单次发包、无状态、存不住任何东西 ✅

优先级:会话 > 只读凭据 > 密码。同时给出多个时按这个顺序取第一个有效的 (浏览器里带着登录态又传别的属误用,按会话走不会出现「用自己的号却按别人的号记账」)。

长期跑的机器人请用只读凭据,别用密码:

  • 它只能查余额与流水,拿到也搬不走你的鱼干;
  • 它可以单独吊销,改密码不会作废它 —— 反过来泄露时也只吊销它,不必改密码 (改密码会连你自己的登录会话一起废掉);
  • 它不跑密码哈希,所以不受 §4 那条「20 次/分」的 CPU 闸门限制。

2.1.1 怎么拿一张只读凭据

在站内自助签发,不需要找站长:登录 raricy → 打开 /fish/api →「签发只读凭据」, 输入一个备注(例如「对账机器人」)和你的登录密码,页面会给出一串令牌。

⚠️ 令牌只显示这一次。 库里只存它的 SHA-256,关掉页面就再也取不回来 —— 没抄走只能吊销重签一张。

用法就是把令牌放进 Authorization 头:

curl -X POST https://raricy.com/api/fish/market/balance \
  -H 'Authorization: Bearer <你签发的令牌>' \
  -H 'Content-Type: application/json' \
  -d '{}'

📌 令牌没有任何前缀 —— 它是一串 43 个字符的 base64url(Aa0…_- 这类字符), 页面给你什么就原样放进 Authorization: Bearer 后面。别因为「看起来不像有前缀」 就以为抄漏了。

注意请求体里不要再放 username/password —— 放了也不看(凭据优先于密码)。

项 口径
有效期 365 天。到期后请求返回 401,需要重新签发
吊销 /fish/api 页面上点「吊销」,立即生效(服务端每次都查库、没有缓存)
权限 只有 read:POST /api/fish/market/balance 与 POST /api/fish/market/transactions
不能做的事 转账:带着令牌打 POST /api/fish/market/transfer 会拿到 403(凭据只授 read)
⚠️ 令牌帮不上的接口 收银台 POST /api/fish/market/pay 与用户搜索 GET /api/fish/market/users 根本不认令牌 —— 它们只认会话 cookie:没会话就是 401 请先登录(不是 403),有会话则令牌被忽略、按会话身份放行
被封禁时 账号被禁言 / 封禁期间,凭据立即失效(403)。站长处理失控机器人的手段对它同样有效

一张凭据对应一个用途。 建议一个脚本签一张、备注写清是谁在用 —— 出事时才能 精确吊销那一张,而不是把所有脚本一起停掉。

2.2 密码说明(用 §2.1 的「密码」那一路时)

  • username 字段用户名或邮箱都认(与网页登录一致)。
  • 校验的是账号密码本身(scrypt 哈希,werkzeug 兼容格式),与网页登录完全同一份凭据: 改密码后旧凭据立即失效。
  • 密码只用于本次校验:不写日志、不回显、不落库、不换取任何长期凭证。

3. 接口

所有接口:POST,Content-Type: application/json,成功一律返回 {"code": 200, ...}(另有 HTTP 200)。

为什么查余额/流水也是 POST:凭据必须放在请求体里。GET 只能把密码塞进 URL, 那会原样进 nginx access log、浏览器历史、Referer —— 等于把密码写进日志文件。

3.1 转账

POST /api/fish/market/transfer
{
  "username": "mybot",        // 必填(无会话时)
  "password": "********",     // 必填(无会话时)
  "to_username": "alice",     // 收款人,二选一:用户名
  "to_user_id": "u_xxx",      //           或用户 id
  "amount": 1,                // 必填,> 0 且最多 4 位小数(最小 0.0001)
  "note": "机器人转账",       // 可选,≤ 30 字,双方流水里都能看到
  "idempotency_key": "wd-20260915-0007"  // 可选,见 §6(强烈建议提现/对账类调用带上)
}

成功:

{
  "code": 200,
  "message": "已转给 alice 1 条小鱼干",
  "amount": 1,
  "balance": 41.5,
  "recipient": { "id": "u_xxx", "username": "alice" },
  "transfer_id": "a1b2c3d4e5f60718",
  "duplicated": false
}

balance 是转账之后发送者的余额(鱼干)。收款人会收到一条站内通知。

⚠️ balance 是「响应那一刻」的余额,不是「原单成交那一刻」的余额。 带幂等键重放 (duplicated: true)时它读的是当下的值 —— 首笔与重放之间若又有别的收支,两次响应 的 balance 会不同。拿重放当收据、用 balance 对账会看错;transfer_id 与 amount 才是可靠的。

duplicated 恒在(新转账为 false):不要靠它缺省来判断这是不是重放。

transfer_id 是这一笔的共享单号(16 位十六进制)。收付双方的流水里是同一个值, 所以它是你对账时唯一可靠的「同一笔」依据 —— 你收款后按它把入账认到自己这边的订单上, 不必猜金额和时间。三条口径:

  • 单号由幂等键派生,因此同键重放回报的是原单的单号,不是新值;
  • 它不是凭证:没有「按单号查一笔转账」的接口,别人拿到它什么也做不了;
  • 存量老流水没有这个字段(值为 null),见 §3.3。

字段口径:

字段 规则
amount 必须是数字或数字字符串;> 0;最多 4 位小数(最小 0.0001,0.00005 → 400)。鱼干在库内是 0.0001 的整数倍,投喂分成产生的小数(如 3.8)可以原样转出。⚠️ 该精度在 2026-09 之前是 1 位小数(0.05 会被拒)—— 现在收得更细,但老金额(0.1、3.8 这种)语义完全没变,按老口径写的机器人不用改
收款人 to_username 精确匹配(区分大小写);用户名不存在 → 404。两个都没给 → 400
note 超过 30 字 → 400(不静默截断);换行/连续空白会被压成单个空格
idempotency_key 可选。1–48 位,仅 A-Z a-z 0-9 _ . : -。语义见 §6
transfer_id 响应字段(请求里没有)。16 位十六进制,收付双方同值。见上
给自己转 400

3.2 查余额

POST /api/fish/market/balance
{ "username": "mybot", "password": "********" }
{ "code": 200, "message": "ok", "user_id": "u_xxx", "username": "mybot", "balance": 42.5 }

3.3 查流水

POST /api/fish/market/transactions
{
  "username": "mybot",
  "password": "********",
  "page": 1,            // 可选,默认 1(非法值回落默认,不报错)
  "per_page": 20,       // 可选,默认 20,上限 100
  "type": "transfer_all" // 可选,与网页筛选条同口径
}

3.3.1 对账请用游标模式(since_id)而不是翻页

翻页会漏也会重:新流水不断插入,页码在两次请求之间会漂移。对账机器人请改用游标:

POST /api/fish/market/transactions
{ "username": "mybot", "password": "********", "since_id": 0, "limit": 100 }
{
  "code": 200,
  "mode": "cursor",
  "transactions": [ /* id 升序,只含 id > since_id 的行 */ ],
  "next_cursor": 4242,
  "has_more": true
}

循环:取回 → 处理(入账)→ 存下 next_cursor,然后下一轮把 next_cursor 原样当 since_id 传回来;has_more 为 true 时立刻再拉一轮(说明还有积压)。这样构造上不会漏、 不会重,也不需要「记住上次看到哪条」这种脆弱做法。

两条纪律:

  • since_id 只认非负整数,非法值返回 400 而不是静默退回翻页模式 —— 静默退回会让你以为在推进游标、其实每轮都从头拿;
  • 取到就入账,不需要任何滞后。 转账的扣款、入账与两条流水(带键时还有幂等 记录)在同一个事务里提交:要么全生效、要么一条都不存在。所以不存在 「先看见一行、几秒后它又没了」的行 —— 那种事来自更早的版本,现在不可能发生, 你也不必靠「只处理若干秒之前的行」去躲它。 ⚠️ 但如果你另有按时间过滤的理由(例如「只处理今天某时刻之前的行」),那个 「某时刻」必须是本站时钟,不是你的本机时钟 —— created_at 是「UTC+8 墙上 时间贴 Z」(见 §3.3 的说明),比标准 UTC 快 8 小时。直接拿 Date.now() 跟它比, 每一行都会显得「来自未来」,于是过滤器会把所有行都挡掉:对账看起来在跑、 日志一行不报,而一笔都没入账。 对齐方式:nowInRaricyClock = Date.now() + 8 * 60 * 60 * 1000。 一份完整实现见 docs/bot/fish-bank-example.md。

type 是筛选项,原样透传给查询;三个「合称」是特例:feed_all = 投喂(含收与支)、 transfer_all = 转账(含转出与转入)、market_all = 练手盘(买入与卖出)。 网页筛选条用的那套取值(这张表就是全部,不多不少):

传什么 筛出
checkin 签到
feed_all 投喂(收 + 支)
transfer_all 转账(转出 + 转入)
market_all 练手盘(买入 + 卖出)
frame_rent 鱼干商城(租头像框的支出)
admin_grant 管理员赠送

不传则返回全部。

⚠️ 流水行里还会出现这张表没列过的 type,别看到陌生值就以为接口坏了。 它们全是历史值,只出现在老流水里,筛选条上没有对应项: feed / feed_receive / transfer / transfer_receive / market_buy / market_sell(合称背后的原始值,用上面的合称筛得到)、 admin_deduct(站长从你账上扣减)、system_compensate(系统补偿)、 feed_backpay(投喂分成补发)、compensate_reverse(补偿冲正)、 sync_adjust(早年与站外账户服务对账时的调整)。

⚠️ 收银台 / 收款码的付款没有专属 type —— 那笔钱在流水里就是一条 transfer(负数)。别去筛一个叫 purchase 的值:它从来不存在, 传了只会得到空列表。

{
  "code": 200,
  "message": "ok",
  "user_id": "u_xxx",
  "username": "mybot",
  "transactions": [
    {
      "id": 123,
      "amount": -1,
      "type": "transfer",
      "description": "转给「alice」:机器人转账",
      "reference_type": "user",
      "reference_id": "u_alice",
      "related_user_id": "u_alice",
      "transfer_id": "a1b2c3d4e5f60718",
      "created_at": "2026-09-14T11:52:03.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "per_page": 20,
  "pages": 1,
  "has_prev": false,
  "has_next": false
}

amount 正数为入账、负数为支出;单位是鱼干。created_at 是本站时钟 (UTC+8 墙上时间贴 Z 标签的假 Z,见 §3.3.1 的说明),不是标准 UTC 瞬间。

transfer_id 是这笔转账的共享单号(见 §3.1)—— 收付双方同值,对账请以它为准:

  • 只有用户间转账(type 为 transfer / transfer_receive)才有;签到、投喂、 管理员赠送、系统补偿等一律是 null;
  • 上线之前的历史流水也是 null,且不会补 —— 旧数据无法可靠反推配对, 猜一个配对意味着可能把两笔钱认成一笔。按 null 处理即可,别当成数据损坏;
  • 转账的两条流水写在同一个事务里:要么都出现、要么都不出现,不会有哪一条先 露头、随后又带着单号一起消失。所以你读到的每一条都是终态,直接入账即可。

4. 限频

维度 配额 说明
只读凭据 · 每账号 120 次/分钟 不跑密码哈希,所以不受下面那条 CPU 闸门约束 —— 这条只是防死循环
只读凭据 · 每 IP 600 次/分钟 同上
密码接口 · 每账号 20 次/分钟 成功也计数 —— 每次请求都要跑一次 scrypt(密码哈希校验),不封顶就是 CPU 放大器
密码接口 · 每 IP 120 次/分钟 同上,成功也计数
密码校验失败 · 每账号 100 次/15 分钟 与网页登录 /api/auth/login 共用同一个桶(同一份凭据、同一个预算)
密码校验失败 · 每 IP 300 次/15 分钟 同上。签发只读凭据时输错密码也走这一对桶
转账 · 每发送者 30 次/小时、200 次/天 参数校验一过就计数(余额不足也算一次:它在计数之后才被判);金额非法 / 收款人不存在 / 给自己转在计数之前被拒,不占额度
转账 · 服务账号 500 次/小时、5000 次/天 白名单账号(站外银行这类)由站长加入 FISH_SERVICE_ACCOUNTS(按 user id);撤销即删配置

热循环请用只读凭据或会话。 「密码接口」那 20 次/分/账号是 CPU 闸门 (每次请求都要跑一次 scrypt),不是业务额度。两条路都不受它管:

  • 只读凭据(§2.1.1):验一张令牌(查库一次)+ 取一次用户行,不跑 scrypt, 120 次/分,且只能读;
  • 会话:POST /api/auth/login 拿 cookie(有效期 30 天)后带 cookie 打同一个接口, 只验一次 JWT。能转账,但那正是你要慎重的理由。

无状态那条(每次发密码)留给「cron 一次性任务 / 用户触发的单次操作 / 存不住任何凭据的 运行环境」。

需要更大额度(几十人以上的银行):找站长把你加入服务账号白名单 —— 转账上限提到 500 次/时、5000 次/天。注意它同时意味着那根「每人 30 次/时」的闸门对你失效 (A → 你 → B 可以当管道用),所以白名单是可撤销的:出事时站长能立刻降回普通配额。

超限一律 429。设计意图:

  • 正常机器人(几分钟查一次余额、偶尔转一笔)离配额很远;
  • 循环里 while(true) transfer 的写法会在 20 次/分钟后被挡;
  • 撞库从哪个门进来都一样贵(与网页登录共用失败预算)。

5. 错误码

HTTP 何时 机器人该怎么做
400 金额非法 / 超 4 位小数 / 余额不足 / 给自己转 / 缺收款人 / 留言超长 改参数,别重试
401 用户名或密码错误 / 两边都没带凭据 检查凭据(用户不存在与密码错误返回同一条文案,不给枚举留信道)
403 账号被禁言 停手,联系站长
404 to_username 不存在 检查用户名
409 幂等键已用于另一笔(收款人 / 金额 / 留言不同),或该键对应的记录状态异常 换一个键重来,见 §6.1
429 见 §4 退避;不要立刻重试
500 本站服务端故障:服务器开小差了,请稍后再试 别当成「这笔没发生」 —— 先查流水确认它到底成交没有,再决定要不要重发(见 §6)

非 JSON 请求体 → 400;username/password 字段缺失(且无会话)→ 401。

📌 别再等 503 了 —— 本文这几个接口都不会返回它。 本站的 503 只属于其它 几处「先决条件不具备」(行情源不可用 → 练手盘拒单、注册 / 建号的兜底、二维码生成 缺配置…),没有一处是「这笔钱没动、可以重试」的意思。 转账 / 收银台遇到的服务端故障就是上面那个 500,它同样不是「可以重试一次」。

6. 幂等与重试(读两遍)

转账接口有两种模式,用不用 idempotency_key 决定:

6.1 带上 idempotency_key —— 可安全重试(推荐)

{ "username": "mybot", "password": "…", "to_username": "alice", "amount": 1,
  "idempotency_key": "wd-20260915-0007" }

服务端按这个键登记一条幂等记录,于是:

再发一次同样的请求 结果
键相同、收款人/金额/留言都相同 返回原结果(duplicated: true),一分钱都不再动
键相同,但换了金额/收款人/留言 409 —— 不会静默改单,换一个键重来
键相同,而那条记录的状态不是「已生效」 409(罕见:只可能命中本站更早版本留下的记录,文案会提示人工查证)。换一个键重来,别当它成交了

所以:超时、断连、5xx 之后,直接用同一个键重发,这是安全的。键由你生成,要求 1–48 位、只含 A-Z a-z 0-9 _ . : -(例如 wd-20260915-0007、order-42)。 不要用「时间戳」这种每次都不同的东西当键 —— 那就等于没带。

📌 键与钱同生共死:幂等记录与转账在同一个事务里提交 —— 要么都生效、 要么都不存在。所以不会出现「键被占住、钱却没动」(那会让你的重试永远拿 409), 也不会出现「钱动了、键没记」(那会让你的重试变成第二笔)。 (更早版本留下的记录是唯一的例外 —— 就是上表第三行那种,它会明确回 409。)

调用方要注意的一条:键要跟着「这笔业务」走,而不是跟着「这次请求」走。 一笔提现在数据库里有个单号,那个单号就该是幂等键。

6.2 不带 idempotency_key —— 重试 = 再转一笔

不提供键时,服务端每次生成一个随机键(transfer-{hash}-{时间戳}-{随机后缀})。 那个随机后缀是为了保证两笔同额转账真的转两次(不被当成同一笔重放而静默去重), 不是去重机制。此时:

  • 每次成功返回 200 = 一笔真实成交;
  • 网络超时 / 连接中断时,你无法从本地判断这笔成没成 —— 正确做法是先查流水(type: "transfer_all")看有没有这笔,再决定是否重发;
  • 服务端不保证「你发了一次 ⇒ 只成交一笔」:那次超时的请求可能已经成交, 而你的重发就是第二笔。这一条只能由调用方保证 —— 用 §6.1 的键。

⚠️ 做提现/对账的机器人请一律用 §6.1 的键。 自动重试 + 不带键 = 迟早双付。

7. 安全建议

  1. 长期跑的机器人用只读凭据(§2.1.1),不要用密码。 密码是全权限的: 泄露即被搬空,而且只能靠改密码止损 —— 那会把你自己所有设备的登录一起踢掉。 只读凭据搬不走钱,泄露了在 /fish/api 点一下吊销即可,且立即生效。
  2. 给机器人单独建号,不要拿站长/主账号跑脚本 —— 万一真需要转账权限,损失以那个账号的鱼干余额为上限。
  3. 只给它需要的鱼干量。转账不可撤回,机器人被劫持 = 余额被搬空。
  4. 凭据/令牌只放在请求头或请求体里,走 HTTPS。别写进 URL、别写进前端页面、 别提交进代码仓库(用环境变量)。
  5. 一个脚本一张凭据。 备注写清是谁在用 —— 出事时才能精确停掉那一张, 而不是把所有机器人一起停掉。
  6. 密码即身份:走「密码」那一路时强度等于密码的强度。定期更换, 改了密码记得同步机器人配置(旧密码立即失效;只读凭据不受影响)。
  7. 机器人也会被禁言、也会被限频 —— 这不是 bug,是刻意的:出问题时站长 能用处理普通用户的手段处理它(包括让只读凭据立即失效), 不需要额外的黑名单机制。

8. 一个完整的例子

BASE=https://raricy.com
USER=mybot
PASS=$BOT_PASSWORD   # 环境变量,别写死在脚本里

# 1. 先看看还有多少鱼干
curl -s -X POST $BASE/api/fish/market/balance \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$USER\",\"password\":\"$PASS\"}"

# 2. 转账 1 条给 alice —— 带幂等键:超时后原样重发这一条命令是安全的
curl -s -X POST $BASE/api/fish/market/transfer \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$USER\",\"password\":\"$PASS\",\"to_username\":\"alice\",\"amount\":1,
       \"note\":\"谢谢帮忙\",\"idempotency_key\":\"wd-20260915-0007\"}"

# 3. 对账:从游标拉增量(取到即可入账,见 §3.3.1)
curl -s -X POST $BASE/api/fish/market/transactions \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$USER\",\"password\":\"$PASS\",\"since_id\":0,\"limit\":100}"

9. 收银台:让用户在你的页面上点一下就付到你的账号

上面那些接口是你的账号在动。但「用户往你这里充值」不该让用户把密码交给你 —— 正确做法是把他送到 raricy 自己的支付页,密码只输在 raricy 的域名下。

这不需要申请、不需要登记、不需要回调地址白名单:收银台就是一个 URL, 任何站点都可以拼。

https://raricy.com/fish/pay
  ?to=<收款人用户名>     # 必填,你的账号用户名
  &amount=1              # 必填,> 0 且最多 4 位小数
  &order=<你的订单号>    # 可选,但**强烈建议给** —— 见下面「必给 order」
  &note=<备注>           # 可选,≤30 字
  &from=<你的站点名>     # 可选,仅展示,且页面会标注「本站不验证商户身份」
  &return=<回跳地址>     # 可选,https/http 绝对地址;支付成功后页面上出现返回按钮

必给 order:不给就可能重复扣款

order 是支付防重的唯一手段,别省。规则:1–32 位,只含 A-Z a-z 0-9 _ . : -, 不合法时页面会给用户一张错误卡(不会静默忽略 —— 静默忽略等于悄悄取消防重保护)。

  • 给了 → 同一个订单号永远对应同一笔支付。用户付完款刷新页面再点一次, 服务端认得出是同一笔,不再扣钱;同一个订单号换了金额则明确报错,不会静默改单。
  • 不给 → 页面每次加载都会生成一个新的支付标识,于是用户付完刷新再付一次就是真的第二笔。 这种重复只能靠你自己对账发现并退款,而转账是不可撤回的(§7 第 3 条)。

一个订单号只该对应一笔生意。若同一个订单号被用在两家不同商户的链接上, 两边各自独立、互不影响(单号里混了收款人,不会撞车)。

用户付完之后,页面上会显示一个凭据号,那就是这笔转账的 transfer_id(§3.1)—— 你可以让用户把它报给你,也可以直接从自己流水的 transfer_id 对上。

用户侧的实际流程:

  1. 浏览器顶层打开上面的链接(别用 iframe:跨站 iframe 里带不过会话 cookie, 用户会看到「未登录」);
  2. 没登录 → 跳 raricy 登录页输密码(你的站点全程接触不到密码);
  3. 页面显示收款人、金额、备注,并要求用户再输一次自己的密码才付款;
  4. 支付成功后页面显示结果,并给出「返回 <你的域名>」按钮。

你必须知道的三条

  1. 回跳结果不可信,以流水为准。 任何人都能直接打开你的 return 地址。 用户回到你的站点后,你的后端要去查自己的流水(游标模式拉 transfer_all,按 note 里的订单号对上),再决定给他记账。 —— 这也意味着你不需要任何共享密钥或回调签名。
  2. from 只是字符串。 页面会明写「本站不验证商户身份」,你的用户看到的是 raricy 的收款人是谁、金额多少,而不是你自称的名字。
  3. 金额由链接给定、用户不能改。所以一笔订单一个链接;不要指望用户在页面上 自己填金额(他要换个金额,就让他走 §3.1 的普通转账)。

别忘了「充值」只是半步:认人

用户充值到你的账号后,你要知道这笔钱属于哪个站外账号。三条可用的做法, 按可靠性排序:

做法 说明
挑战存款(推荐,无需任何人批准) 让用户先转 0.1,备注里带一串一次性验证码;你从流水的 related_user_id 认人 —— 那是站点流水的权威字段,伪造不了。验证通过后再把 0.1 退给他,或直接计入余额。⚠️ 别把 0.1 改小:它是对外可观测的约定(用户按你的说明去转),改小会让存量的认领匹配规则漂移 —— 精度提到 4 位小数不等于你得跟着改
订单号 + 人工核对 金额与时间都吻合才认,量大就不可行
OAuth(需要站长登记应用) docs/oauth.md:用户授权后你拿到他的 raricy user id。最省事,但要站长建应用 —— 铁了心「互不打扰」的话就走上面那条

⚠️ 永远不要靠备注文本认人。备注是任何人都能写的自由文本, 「我是 alice」并不能证明他就是 alice。认人只能用 related_user_id, 或者你能验证的凭据。

10. 收款回调:让 raricy 主动通知你

§3.3 的轮询能用,但慢(要等下一轮)且贵(每几分钟一次请求)。回调把「谁主动」 反过来:钱一到账,我们就推一条通知到你的服务器。

10.1 登记地址

在站内 /fish/api 页面上填一个 https 地址并确认(要再输一次登录密码), 页面会给出一个签名密钥 —— 它也只显示这一次,请立刻存进你的配置。

地址的要求:

要求 为什么
必须 https 通知里有金额与用户名,线路上的明文不可接受
必须直接返回 2xx 我们不跟随重定向(跟随会打开一类绕过)。要跳转请自己返回 2xx
不能指向内网 / 回环 / 保留地址 我们的服务器在内网里,这类地址会被当成探测内网。登记时与每次投递前都会查
不能带用户名密码 那是钓鱼惯用手法

一个账号一个地址。要换就改,改地址不会换密钥(免得你已经写好的验签代码失效); 要换密钥页面上有单独的「换密钥」按钮。停用只是一个开关,随时可以重新启用。

10.2 请求长什么样

一次到账,我们会向你登记的地址发一个 POST:

POST /fish/callback HTTP/1.1
Host: 你的站点
Content-Type: application/json
X-Raricy-Event: fish.transfer.received
X-Raricy-Delivery: 9f2c1e...(32 位十六进制,接收方去重用的)
X-Raricy-Timestamp: 1758276123(Unix 秒)
X-Raricy-Signature: v1=3f9a...(见 §10.3)

正文:

{
  "event": "fish.transfer.received",
  "delivery_id": "9f2c1e...",
  "occurred_at": "2026-09-19T18:22:03.000Z",
  "transfer_id": "a1b2c3d4e5f60718",
  "to":   { "user_id": "u_xxx", "username": "mybot" },
  "from": { "user_id": "u_yyy", "username": "alice" },
  "amount": 1.5,
  "note": "order-20260919-0007",
  "balance_after": 42.5
}
  • transfer_id 就是 §3.1 的那个共享单号 —— 拿它把回调与你账本里的那一笔对上, 不必猜金额和时间;
  • balance_after 是这次到账之后你的余额(鱼干),省掉一次查询;
  • amount / balance_after 最多 4 位小数(最小刻度 0.0001)。⚠️ 用浮点接收 (JSON 数字本来就是),别自己 Math.round(n * 100) / 100 收到两位 —— 那样每笔都会 少记一点,几百笔之后你的账与站内流水就对不上,而且不报任何错;
  • occurred_at 是本站时钟(UTC+8 墙上时间贴 Z 标签,同 §3.3 的 created_at), 不是标准 UTC 瞬间。

回调只在有人转给你的账号时发。签到、投喂、管理员赠送、系统补偿都不会触发。

10.3 验签(必须做)

密钥在 §10.1 拿。签名算法:把 时间戳 + "." + 正文 用 HMAC-SHA256 算, 密钥是你的签名密钥,结果取小写十六进制,前面加 v1=。

把时间戳一起签进去是刻意的:否则同一段正文可以被无限重发(重放)。

Node.js:

const crypto = require('node:crypto');

// ⚠️ rawBody 必须是**原始字节**。先 JSON.parse 再 stringify 会改掉空白与键序,
//    算出来的签名对不上 —— 这是最常见的接入问题。
function verify(rawBody, headers, secret) {
  const ts = headers['x-raricy-timestamp'];
  const got = headers['x-raricy-signature'] ?? '';
  const expect = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');
  // 必须用定时安全比较,别用 ===(会泄露前缀匹配长度)
  const a = Buffer.from(got);
  const b = Buffer.from(expect);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
  // 时间戳要新鲜(挡重放)。注意:**重试时时间戳是新的**,所以这一步不能替代去重。
  return Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
}

Python:

import hmac, hashlib, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers['X-Raricy-Timestamp']
    got = headers.get('X-Raricy-Signature', '')
    expect = 'v1=' + hmac.new(
        secret.encode(), f'{ts}.'.encode() + raw_body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(got, expect):
        return False
    return abs(time.time() - int(ts)) <= 300

10.4 三条你必须知道的

  1. 可能重复,请按 X-Raricy-Delivery 去重。 投递是「至少一次」:我们把请求发出去 但没记下结果时(进程重启、网络抖动),这一条会被重投。同一个 delivery_id 在重试之间不变 —— 存下它,重复的丢掉即可。这一步不能省。
  2. 失败我们会自己重试,间隔约 10 秒 / 1 分钟 / 5 分钟 / 30 分钟 / 2 小时 / 6 小时, 之后放弃(那条记录标记为「已判死」)。放弃不会停用你的地址 —— 后续到账照样发。 /fish/api 页面首次打开显示最近 10 条投递记录与失败原因(保存地址 / 换密钥 / 停用之后会重新拉取,那时是 20 条);接口默认也是 20 条。
  3. 回调不是对账本身,只是提醒。 收到回调后仍然建议按 §3.3.1 的游标拉一次流水 落账 —— 回调可能丢(比如我们这边判死了),而流水是权威的。回调的价值是 把「什么时候该拉」从定时轮询变成事件驱动。

回调里没有幂等键、也没有对方的邮箱 —— 只给你对账需要的那几个字段。

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