鱼干机器人接入说明(无状态接口)
面向站外开发者。读完本文即可写一个会转账的机器人,无需阅读本站源码。
本文是这几个接口的唯一对外口径。字段、限频数值、错误码均照实抄录, 若与源码不符以源码为准(也请提 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 是用户自己在站内付款,不是机器人接口,故不在此表。)
三条硬约束:
- 只能动自己的账号。 凭据是谁的,就只能以谁的名义转账/查询 —— 没有「管理员代操作」这类接口。
- 重试 = 再转一笔。 服务端不做请求去重:每一次成功返回 200 的调用都是
一笔新交易(幂等键带随机后缀,见 §6)。网络超时不代表没成交 ——
请先查流水(
transfer_all)确认,再决定要不要重发。别无脑重试。 (带着 §6.1 那个idempotency_key时另说:同键重发是安全的,不必先查流水。) - 被禁言的账号一律 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. 安全建议
- 长期跑的机器人用只读凭据(§2.1.1),不要用密码。 密码是全权限的:
泄露即被搬空,而且只能靠改密码止损 —— 那会把你自己所有设备的登录一起踢掉。
只读凭据搬不走钱,泄露了在
/fish/api点一下吊销即可,且立即生效。 - 给机器人单独建号,不要拿站长/主账号跑脚本 —— 万一真需要转账权限,损失以那个账号的鱼干余额为上限。
- 只给它需要的鱼干量。转账不可撤回,机器人被劫持 = 余额被搬空。
- 凭据/令牌只放在请求头或请求体里,走 HTTPS。别写进 URL、别写进前端页面、 别提交进代码仓库(用环境变量)。
- 一个脚本一张凭据。 备注写清是谁在用 —— 出事时才能精确停掉那一张, 而不是把所有机器人一起停掉。
- 密码即身份:走「密码」那一路时强度等于密码的强度。定期更换, 改了密码记得同步机器人配置(旧密码立即失效;只读凭据不受影响)。
- 机器人也会被禁言、也会被限频 —— 这不是 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」
¬e=<备注> # 可选,≤30 字
&from=<你的站点名> # 可选,仅展示,且页面会标注「本站不验证商户身份」
&return=<回跳地址> # 可选,https/http 绝对地址;支付成功后页面上出现返回按钮
必给 order:不给就可能重复扣款
order 是支付防重的唯一手段,别省。规则:1–32 位,只含 A-Z a-z 0-9 _ . : -,
不合法时页面会给用户一张错误卡(不会静默忽略 —— 静默忽略等于悄悄取消防重保护)。
- 给了 → 同一个订单号永远对应同一笔支付。用户付完款刷新页面再点一次, 服务端认得出是同一笔,不再扣钱;同一个订单号换了金额则明确报错,不会静默改单。
- 不给 → 页面每次加载都会生成一个新的支付标识,于是用户付完刷新再付一次就是真的第二笔。 这种重复只能靠你自己对账发现并退款,而转账是不可撤回的(§7 第 3 条)。
一个订单号只该对应一笔生意。若同一个订单号被用在两家不同商户的链接上, 两边各自独立、互不影响(单号里混了收款人,不会撞车)。
用户付完之后,页面上会显示一个凭据号,那就是这笔转账的 transfer_id(§3.1)——
你可以让用户把它报给你,也可以直接从自己流水的 transfer_id 对上。
用户侧的实际流程:
- 浏览器顶层打开上面的链接(别用 iframe:跨站 iframe 里带不过会话 cookie, 用户会看到「未登录」);
- 没登录 → 跳 raricy 登录页输密码(你的站点全程接触不到密码);
- 页面显示收款人、金额、备注,并要求用户再输一次自己的密码才付款;
- 支付成功后页面显示结果,并给出「返回 <你的域名>」按钮。
你必须知道的三条
- 回跳结果不可信,以流水为准。 任何人都能直接打开你的
return地址。 用户回到你的站点后,你的后端要去查自己的流水(游标模式拉transfer_all,按note里的订单号对上),再决定给他记账。 —— 这也意味着你不需要任何共享密钥或回调签名。 from只是字符串。 页面会明写「本站不验证商户身份」,你的用户看到的是 raricy 的收款人是谁、金额多少,而不是你自称的名字。- 金额由链接给定、用户不能改。所以一笔订单一个链接;不要指望用户在页面上 自己填金额(他要换个金额,就让他走 §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 三条你必须知道的
- 可能重复,请按
X-Raricy-Delivery去重。 投递是「至少一次」:我们把请求发出去 但没记下结果时(进程重启、网络抖动),这一条会被重投。同一个delivery_id在重试之间不变 —— 存下它,重复的丢掉即可。这一步不能省。 - 失败我们会自己重试,间隔约 10 秒 / 1 分钟 / 5 分钟 / 30 分钟 / 2 小时 / 6 小时,
之后放弃(那条记录标记为「已判死」)。放弃不会停用你的地址 —— 后续到账照样发。
/fish/api页面首次打开显示最近 10 条投递记录与失败原因(保存地址 / 换密钥 / 停用之后会重新拉取,那时是 20 条);接口默认也是 20 条。 - 回调不是对账本身,只是提醒。 收到回调后仍然建议按 §3.3.1 的游标拉一次流水 落账 —— 回调可能丢(比如我们这边判死了),而流水是权威的。回调的价值是 把「什么时候该拉」从定时轮询变成事件驱动。
回调里没有幂等键、也没有对方的邮箱 —— 只给你对账需要的那几个字段。