一、认证
登录在系统浏览器里完成,走论坛的 OpenID Connect(oidc-integration.md):
- 超级管理员在后台建一个
first_party(官方)、public(公开,无 secret) 的应用,
回调地址https://sb.sb/app/callback/,允许的 scope 至少包含openid profile email forum offline_access,并勾上 「官方客户端」
(oidc_clients.official_app,迁移 0278)。只勾手机 App 这一个应用。 - 客户端走授权码 + PKCE(S256),请求 scope
openid profile forum offline_access
(加email可拿到地址)。拿到 access token(JWT)和 refresh token。 - 每个请求带
Authorization: Bearer <access_token>。接口不读会话 Cookie,
所以也不检查 CSRF token。 - access token 15 分钟,401
invalid_token时用 refresh token 换新的再重试一次;
刷新也失败就是登出了。登出调/oidc/revoke吊销 refresh token。 - 论坛侧任何一扇门结束会话(封禁、改密、退出所有设备)都会让 token 失效,
客户端下一次请求收到 401 即回到未登录态。 - 登录页上的第三方登录有「通过 Apple 登录」(配置了
BBS_OAUTH_APPLE_*时出现),
满足商店审核指南 4.8:用了 Google、GitHub 登录的 App 要同时提供 Apple 登录。客户端
不用为此做任何事,它就在系统浏览器打开的那张登录页上。
不带 Authorization 的请求是访客:可以读公开内容,写入一律 401。
官方客户端是后台勾了「官方客户端」的那一个 first_party 应用(安全审查 2026-10-25),
不是所有 first_party 应用:站长别的系统也是 first_party、也持有 forum(读等级和余额、
走 points:spend 扣费),它们的 token 不该能签到、打赏、换出网页会话、替版主删帖。
官方客户端的 token 缺 forum scope 是 403 insufficient_scope。其余所有应用——会员自己的
应用,以及没勾「官方客户端」的 first_party 应用——走 § 九:靠 api:read / api:write /api:messages 进来,只能用 § 九 表里标了「会员应用」的接口,其余的是 403client_not_first_party;没有管理权限,要扣烧饼的动作被拒绝(paid_action),/me/ 不给 id。
只有 forum scope 的应用(已审核的第三方,或没勾的 first_party——它们用 userinfo 读等级和余额)
在这里什么都进不了,是 403 insufficient_scope;userinfo 和 /oidc/api/points/charge 照旧。
部署注意:这一项上线后,管理员要在后台「OpenID 应用」里打开手机 App 那个应用,勾上
「官方客户端」保存(只有超级管理员看得到这个勾),否则 App 的签到、打赏、推送、换票全部 403。
二、通用约定
地址以
/结尾,和论坛的页面一样;少了斜杠会被 301 到带斜杠的地址。请求体和响应体都是 JSON(
Content-Type: application/json)。请求体里不认识的
字段会被忽略,所以新版客户端可以先于服务端发字段。空请求体等于{}。时间一律 UTC、RFC 3339(
2026-10-13T12:00:00Z),客户端按设备时区显示。错误的形状和 OIDC 扣费接口相同:
{ "error": "refused", "error_description": "标题和内容不能为空" }error_description是写给会员看的中文,直接显示即可。error的取值:状态 error含义 400 refused业务上拒绝(内容为空、太短、已投过票、烧饼不足……),说明在 error_description400 invalid_request/invalid_cursor请求的形状不对 401 unauthorized/invalid_token没带 token,或 token 无效、过期、已吊销 403 forbidden没有这个权限(版块不许发、禁言、未验证邮箱……) 403 client_not_first_party/insufficient_scope/user_unavailable/account_closing/email_unverified见字面 403 posting_notice_required从没发过帖的会员还没读过发帖提示,主题和回帖都发不出;响应另带 posting_notice(提示接口的绝对地址),按「首次发帖提示」一节画弹窗403 paid_action会员自己的应用做了一件按规则要扣烧饼的事(要扣烧饼的点踩、点灭、上传,要付挖坟费的回帖),被拒绝、没扣钱(§ 九) 404 not_found不存在,或对你不可见(拉黑、屏蔽的版块、注销中的账号) 409 posting_notice_not_started发帖提示的「我已阅读」在计时开始之前(或计时过期后)提交:先 start再确认409 request_in_flight同一个 Idempotency-Key的上一次请求还没处理完,稍后用同一个键重试422 idempotency_key_reused同一个 Idempotency-Key配了不同的请求体:客户端的 bug415 unsupported_media_type请求体不是 JSON 429 rate_limited限流:每地址每小时 3000 次,每会员每小时 300 次写入,外加每个动作自己的限制;会员自己的应用另有每应用的限制(§ 九) 429 too_soon发帖间隔还没到(全站的 limits.post_interval_seconds,或版块自己的forums[].post_interval_seconds):响应带Retry-After头和retry_after(秒),等这么久用同一个Idempotency-Key重试即可(429 不会被幂等键记住,任何拒绝都不会)503 oidc_disabled/unavailable站点没开 OpenID,或服务暂不可用 500 server_error服务端错误,原因在日志里 地址:页面地址(
url、box.url、通知的path、pages.*、docs.*)是站内相对
路径,拿/site/的base_url拼;它们是网页地址,客户端按 § 七 用url_scheme
解析成自己的页面。图片类地址(avatar、头像框和徽章图、上传结果的url、换票的url)可能是绝对地址(CDN 或图片代理)。两种都可能出现在body_html里,渲染时把base_url作为 base URL。可选字段:文档里说「有则给」「没有就不出现」的字段(会员的
name_color、group、medals、signature_html、pet,/site/的smoji_url,没配的功能对应的字段……)
没有值时整个键不出现;其余字段总是出现,空值是null、""或[]。?limit=超过上限按上限算,不报错。限流:访客按地址每小时 3000 次;登录后只按会员计(读写合计每小时 3000 次,
写入另有每小时 300 次),不按地址——手机都在运营商 NAT 后面。搜索、@ 补全这些动作
自己的限制也按会员计。幂等键:写入(POST)可以带
Idempotency-Key: <8 到 64 位字母数字或 _ . : ->,
代表「用户的这一次操作」。网络断在服务端已处理、回应没回来的时候,客户端用同一个键
重试,服务端不再做一遍,而是原样返回第一次的回应(状态码和 JSON 一样,另带Idempotent-Replayed: true响应头)。键按会员、方法、路径分开,保留 24 小时。只有成功
(2xx)会被记住;任何拒绝和错误都不记,重试会真的再做一次——被拒绝的请求什么也没做,
再做一次是安全的,而且得到的是现在的答案(比如充值之后不再是「可用烧饼不足」)。
上一次还在处理时(上传可能要好几分钟,处理期间一直算在处理)重试得到 409request_in_flight;
同一个键换了请求体是 422idempotency_key_reused。上传不比对请求体。建议发帖、回复、
私信、打赏、上传都带上,每次用户操作生成一个新键。分页用游标:列表响应带
next,非空就带着?cursor=<next>取下一页,空字符串
表示到底。游标对客户端不透明,不要解析它。?limit=可选,有上限。会员的形状(出现在
author、sender、other、last_reply_user等处):{ "uid": 6666, "username": "vincent", "avatar": "https://…", "url": "/u/6666/", "level": 9, "level_name": "…", "uid_vanity": true, "is_staff": true, "name_color": "rainbow", "uid_gold": true, "uid_style": "d-rose", "avatar_frame": {"key": "gold", "url": "https://…/frames/gold-b37e2b7a.webp", "hole": "0.51", "name": "黄金头像框"}, "level_badge": {"url": "https://…/levels/level-1-….webp", "width": 315, "height": 96, "tier": 1}, "verifies": [{"name": "…", "value": "…", "color": "#…"}] }uid是会员被展示和寻址的号码(public_uid),不是数据库 id。作者已注销时author为null,客户端显示「火星人」。积分商城卖的外观:
name_color(名字颜色卡)和uid_style(靓号标样式)是键名,
怎么画在GET /api/v1/cosmetics/;uid_gold是自有靓号标(金色);avatar_frame
是图片,头像缩成hole(占框宽度的比例)大小的圆放在正中,框比头像大 40%;level_badge是 Lv.7 起的等级徽章图,数字由客户端画在图右边的空胶囊上(位置见
cosmetics 的level_badges[].rules),Lv.6 以下没有这个字段、显示文字Lv.N。
没有的字段就是没戴。
三、读取
GET /api/v1/site/
启动时拉一次。地址形状(url_scheme)管理员可改,客户端不要写死 /t/。带 token 调用时
版块的 can_post、post_interval_seconds 按该会员算。
{ "name": "烧饼", "base_url": "https://sb.sb", "version": "…", "min_app_version": "",
"url_scheme": { "topic_prefix": "t", "forum_prefix": "go", "user_prefix": "u", "seo_slugs": false },
"forums": [ { "id": 1, "slug": "general", "name": "综合讨论", "description": "…", "url": "/go/general/",
"can_post": true, "can_reply": true, "post_interval_seconds": 0 } ],
"limits": { "topic_max_chars": 20000, "reply_max_chars": 10000, "topic_title_max_runes": 120,
"post_interval_seconds": 5, "topics_per_page": 30, "replies_per_page": 30,
"message_max_runes": 2000, "checkin_message_runes": 30, "search_min_chars": 2,
"report_detail_runes": 500, "tip_tiers": [5, 10, 50], "tip_enabled": true,
"upload_max_bytes": 20971520, "uploads_per_hour": 30, "upload_mib_per_hour": 200 },
"features": { "points": true, "lights": true, "oidc": true, "push": true, "signups": true,
"uploads": true, "tips": true },
"notification_kinds": [ { "kind": "reply", "label": "回复" }, … ],
"report_reasons": [ { "value": "spam", "label": "垃圾广告" }, … ],
"docs": { "rules": "/pages/rules/", "terms": "…", "privacy": "…", "contact": "…" },
"pages": { "personal": "/settings/personal/", "shop": "/shop/", … },
"smoji_url": "https://…/smoji.json?ver=…",
"oidc": { "issuer": "https://sb.sb/oidc", "discovery": "https://sb.sb/oidc/.well-known/openid-configuration" } }
(值是示例,路径以实际返回为准。)post_interval_seconds 有两层:limits 里是全站任意两次
发帖(主题和回复都算)之间至少隔几秒;forums[] 里是在这个版块开两个新主题之间至少隔
几秒(按你所在的用户组,只管新主题、不管回复,0 是不限)。features.tips 和limits.tip_enabled 是同一个值,读哪个都行。features.uploads 为
false 时编辑器不显示上传按钮;upload_max_bytes 是单个文件上限,uploads_per_hour /upload_mib_per_hour 是每会员每小时最多传几个、几 MiB;自己的存储用量和上限在 /me/ 的storage。notification_kinds
是通知页分类标签的来源。report_reasons 每项是 {value, label},举报时传 value。
表情索引(smoji_url,没配表情服务时不出现)的格式:
{ "version": 2, "base": "https://…/{pack}/{id}.webp",
"packs": [ { "id": "tieba", "label": "贴吧", "base": "(可选,这个包自己的模板)",
"items": [ { "id": "tieba_001", "label": "哈哈" } ] } ] }
一张表情的地址是包的 base(没有就用顶上的)把 {pack}、{id} 换成 URL 编码后的packs[].id、items[].id。索引不带尺寸:表情插进正文是 。
body_html 里的图:表情是 <img class="smoji">,按文字处理——行内、高 1.55 倍字号、
宽度按比例、垂直对齐往下 0.35em、不进灯箱(网页的 .post-content img.smoji);emoji 是<img class="emoji">(Twemoji 图),同样行内;其它 <img> 是正文图片,点开进灯箱。
GET /api/v1/cosmetics/
怎么画会员买的外观和站点的配色,不需要登录,可以缓存(5 分钟)。全部从站点实际下发的
样式表里读出来,不另抄一份:
theme.light/theme.dark:设计变量(--bg、--text、--brand……),和/design.md列的是同一份。name_colors[]:{key, label, rules},rules是画这个颜色的 CSS 规则{selector, media, supports, container, declarations: [[属性, 值], …]}
(后三个是规则所在的@media/@supports/@container条件,没有就不出现),
含暗色主题(选择器带data-theme="dark"或 media 是prefers-color-scheme: dark)
和减少动效的版本。
纯色只有color;彩虹是渐变文字(background+animation);霓虹、卡通、波普
是text-shadow。uid_styles[]:{key, series: capsule|duo, rules},规则里主要是自定义属性
(--u1…--u4、--t1、--rim1……);它们怎么用,在响应顶层的uid_base
里:{vanity, gold, duo_tile, duo_plate},各是一组规则(胶囊、自有靓号标的金色、
双拼的「靓」字块和号码牌)。avatar_frames[]、level_badges[]:全部图片和尺寸;frame_base是框怎么套在头像上(--frame-hole即各框的hole)。keyframes:上面规则里animation引用的关键帧,按名字。
帖子正文 HTML 里被 @ 的人名也带 uname-<key> 这个 class。
GET /api/v1/topics/
帖子列表。参数:forum=<slug>(某版块;不带是首页)、sort=comment|post
(comment 按最后回复,post/new 按发帖时间;不带时是会员自己的首选——个性设置
可以把首页第一栏换成最新发帖,响应的 sort 说明实际用的是哪个)、kind=pinned|featured|poll|bounty|prize|redpacket|hot(hot 是侧栏的「今日热议」,
当天回复最多的 30 个,不分页——next 是空串——不并置顶帖,只用于首页)、author=<uid>(某人的主题)、following=1(关注的人的主题,需登录,访客 401)、cursor、limit(≤50)。响应 { "topics": [...], "sort": "comment", "total": n, "next": "…", "forum": {...} }。
第一页会把置顶帖并到最前面(pinned_here: true)。回复数和最后回复人是这个读者看到的:
拉黑的人(任一方向)的回复不算。每行:
{ "id": 1024, "title": "…", "url": "/t/1024/", "forum": {"id": 1, "slug": "general", "name": "综合讨论"},
"author": {...}, "created_at": "…", "last_reply_at": "…", "last_reply_user": {...},
"reply_count": 47, "view_count": 1204, "like_count": 36, "dislike_count": 0,
"pinned": false, "pinned_here": false, "featured": true, "closed": false,
"kind": "", "special_ended": false, "highlight_color": "#…" }
kind 是 bounty / poll / prize / redpacket 或空;这些特殊帖第一版请用
WebView 打开 url。
GET /api/v1/topics/{id}/
帖子正文和一页楼层。和网页一样有两种样子(docs/post-layout.md),?layout=tree|flat
选,不带时按会员自己的个性设置(访客和默认都是 tree),响应的 layout 说明是哪种:
tree(楼中楼,默认):按一级楼层分页(直接回主题的回帖),floors[]每一项
是一楼,带box——回这一楼以及楼里互相回的,按楼层先后,第一页(每页条数是后台的
「楼中楼每页条数」)。box是{replies, total, next, prev, url},翻页用GET /api/v1/topics/{id}/floors/{no}/?cursor=。flat(简洁版):replies[]按全部回帖分页。
cursor、limit(≤100)分页;floor_count 是分页的总数(tree 是一级楼层数,flat
是回复数),reply_count 始终是全部回复。?reply_id= 或 ?reply=<楼层号> 直接打开
那条回复所在的那一页(tree 下它所在的框也翻到它那一页),响应带 focus: {no, root},prev 是上一页的游标(第一页没有就是空串)。登录会员打开第一页(不带 reply)计一次浏览。
翻页:next 是下一页的游标(空串是到底了),prev 是上一页的。
在列表行的字段之上多出:body_html(渲染好的正文,客户端不要自己解析 Markdown)、author(带作者栏,见下)、forum、locked(访客不可见的帖子:只有标题,floors /replies 为空)、can_reply、reply_order_desc、edited_at、tip_points、tip_count、bounty(悬赏帖才有),登录后还有 my_vote(1/-1/0)、bookmarked、bookmark_count、can_manage、can_edit(只有作者和版主可以改,别人改是 403)、can_delete(楼主能
不能删:悬赏、投票、抽奖帖按站点设置不能删)、my_tip(自己打赏过多少,0 是没打赏,一个
主题只能打赏一次)和 can_tip(打赏开着时才有;自己的帖子、已经打赏过、烧饼不够最小一档
时为 false)、following_author(是否已关注楼主,自己的帖子没有),作者和版主拿到 body(原文)。作者被禁言时 body_html 是一句「该用户已被
禁言,内容已隐藏」、muted: true,和网页一样。
一楼(floors[] 或 replies[] 的一项):
{ "id": 9, "no": 1, "parent_no": 0, "root_no": 0, "author": {...}, "body_html": "…",
"created_at": "…", "edited_at": null, "light_count": 8, "my_light": 0, "saved": false,
"is_op": false, "url": "/t/1024/?reply_id=9", "can_manage": true, "can_edit": true, "body": "…",
"box": { "replies": [...], "total": 3, "next": "", "prev": "", "url": "/t/1024/floor/1/" } }
no是楼层号,删除不会重排。删掉的楼留一行{id, no, deleted: true, deleted_by: author|staff|system, is_op},tree 下它的框照样在。拉黑的人的楼直接不出现(只留缺号)。parent_no是这条回的哪一楼(0 是直接回主题),root_no是它在哪个一级楼层的框里
(0 是它自己就是一级楼层)。发帖时按正文开头的「@名字 #N」定下,编辑不改。- 楼层的
author多了作者栏:group: {name, kind, short, color, ink}、medals[]: {key, name, img, title}、wealth、post_count、essence_count、fin_badge、signature_html(个性签名,后台关了签名或作者被禁言时没有)、pet: {sheet, cell_w, cell_h, frames, name, hello}(宠物:精灵图和每行帧数;回帖里的宠物会员可以在个性设置里
关,那时只有主帖有)。 - 框里的一条(
box.replies[])和楼层的字段相同(id、no、parent_no、root_no、light_count、my_light、saved、can_manage、can_edit、url……,可以点亮、
收藏、编辑、删除、举报、回复),只是:author只有会员形状(小头像一行,没有作者栏),body_html是压平成一行的版本(去掉开头的「@名字 #N」,图片变成「[图片]」链接,和
网页的框一样),回的不是这一楼本身时带to(被回复的人,会员形状,已注销是null)。 my_light、saved(收藏了这条回复)、can_manage、can_edit登录后才有(不是自己
的、也不是版主时后两个是 false);can_manage为 true 而can_edit为 false 是抽奖回帖,
不能编辑也不能删。- 自己的主题不能投票、自己的回帖不能点亮(400
refused),按钮按author.uid是否是
自己来画。
GET /api/v1/topics/{id}/floors/{no}/
一级楼层 no 的框的一页:{root, replies, total, next, prev}。root 就是这个一级楼层
的楼层号(= 路径里的 no),replies 的每一项同上面的框里一条,cursor、limit
(≤200,默认后台的楼中楼每页条数)。帖子对访客不可见时 403。
GET /api/v1/users/{uid}/
资料页,名片要的会员形状(等级、徽章、头像框、名字颜色……)都在里面。注销中或已回收的
账号 404。会员不许访客看资料时,访客只拿到会员形状加 locked: true。其余字段:bio、growth、points(这两个只给登录的人,访客看得到等级、看不到余额)、online、created_at、following_count、follower_count、topic_count、reply_count;登录后看别人还有 following、blocked(你是否拉黑了
对方)、can_message(任一方拉黑了就是 false)和 message_token——对方的私信地址:
会话一律用对方的 token 寻址(两个人看同一段会话,各自用的是对方的 token;GET /api/v1/messages/ 的 conversations[].token 也是对方的),GET /api/v1/messages/{token}/ 打开会话(拉黑后也能看历史,只是 can_send 为 false)、POST 同一地址发私信,不需要先有会话。用自己的 token 是 404。
GET /api/v1/users/{uid}/followers/、/following/、/replies/
粉丝、关注、回复三个标签。前两个给 users[](会员形状加 since),第三个给replies[](id、no、excerpt、created_at、light_count、url、topic)。
都带 total、next。会员不许访客看资料时,访客取这三个都是 403(GET /topics/?author=<uid>
同样 403),和网页上资料页的标签对访客收起一致;你和对方任一方拉黑了,三个都是 404。
被禁言的会员,回复的 excerpt 是「该用户已被禁言,内容已隐藏」,和网页一样。
GET /api/v1/me/(需登录)
自己:资料字段加 id、email_verified、can_speak、can_manage、in_debt
(烧饼为负,不能发帖)、unread_notifications、unread_messages、next_level、checkin.checked_today / checkin.streak、topic_count、reply_count、online
(总是 true)、bookmark_topic_count / bookmark_reply_count(收藏的主题和回帖数)、upload_cost(上传一个附件扣几个烧饼,0 是免费),以及:
wallets:烧饼以外的钱(docs/currency.md)——dami(大米,商城用)、maodou
(毛豆,彩票用,1 烧饼 = 1000 毛豆)、game_coins、mantou: {available, frozen}
(证券)。读不到的那项不出现。personal:个性设置里影响客户端怎么画的几项,在网页的个性设置里改(/site/的pages.personal):post_classic:会员选了简洁版帖子。帖子接口不带?layout=时就按它给flat
(否则tree),所以客户端照响应的layout画即可。tree_view:只在post_classic为 true 时可能为 true——网页在简洁版里把平铺的回复
按引用关系缩进成树。客户端在flat下要做同样的缩进,按每条的parent_no。home_sort_created(首页默认按发帖时间)、pet_replies(回帖里显示宠物)、link_last_reply(列表点进去直接到最后一条回复)、smoji_zoom(表情悬浮放大)、embed_youtube(YouTube 链接嵌成播放器)。
storage:{used_bytes, limit_bytes},附件占用和上限(-1是不限)。
GET /api/v1/users/mention/?q=&topic_id=(需登录)
写正文时 @ 的补全:名字以 q 开头的人(按论坛认人的规则,繁简、大小写不分,和发出去以后
@ 能不能链上是同一套),机器人也在里面。带 topic_id 时这个帖子里的人(楼主和回过帖的)
排在前面,q 为空时只列他们。拉黑的人不出现。limit ≤20,默认 10。响应 {users: [...]}。
每会员每小时 1200 次。
GET /api/v1/me/bookmarks/replies/(需登录)
收藏的回帖,新的在前:replies[](id、no、excerpt、author、created_at、light_count、url、topic)、total、next。收藏的主题是GET /api/v1/topics/?bookmarked=1。
GET /api/v1/me/points/(需登录)
自己的烧饼明细,最新在前:entries[].points / growth / reason(代码)/reason_label(网页上显示的说法)/ note / balance_points / created_at,加points、growth、total、next。
GET /api/v1/notifications/(需登录)
通知列表(不含私信),最新在前,unread、total、next。每条:id、kind
(reply / mention / like / light / points / invite / system)、kind_label、content(Markdown)、content_html、sender、read、created_at,
和帖子有关的带 topic: {id, title, url, reply_id, reply_no}(关于主题本身而不是某条回复的
通知——支持、移动——后两个是 null)。
列表在 notifications 键下。unread 是整个通知箱的未读数(不受 ?kinds= 影响),unread_by_kind 是按类别的未读数 {"reply": 3, …}(没有未读的类别不出现),给分类标签
的角标用。读列表不会标记已读;看完了调 POST /api/v1/notifications/read/,和网页一样
是全部已读,没有单条已读。?kinds=reply,mention 只取这几类(客户端的分类标签,total 也按它算),未知的类别 400。
GET /api/v1/me/blocked/(需登录)
自己拉黑的人,users[],供解除。
GET /api/v1/messages/(需登录)
会话列表:conversations[].other / token / preview / last_at / unread /from_me / pinned,置顶的在前。
GET /api/v1/messages/{token}/(需登录)
一个会话,最早在前,默认最近 40 条;?before=<id> 取更早的,?limit=(≤100)。
响应:other、messages[](id、from_me、content、content_html、reply_to
(引用的那条的 id,没引用是 null)、created_at、read)、total、has_earlier、oldest_id、gone(对方已注销,此时没有 other)、blocked(任一方拉黑)、can_send、pinned、online。聊天是从下往上翻的,所以这里不用游标:?before=<oldest_id> 取更早的
一页。会话列表(GET /api/v1/messages/)一次给全,不分页。打开会话即标记对方的消息已读,和
网页一样。
GET /api/v1/checkin/(需登录)
签到状态:enabled、checked_today、streak、longest、total_days、month_days、today_count、crit_pct(今天的暴击率)、crit_cards、to_reward_7、to_reward_30、to_reward_100、to_reward_365。
四、写入
全部需要登录,且邮箱已验证(否则 403 email_unverified)。发帖、回复、投票、
点亮、私信还要求账号可以发言(禁言 403)。
POST /api/v1/topics/
{ "forum": "general", "title": "标题", "body": "Markdown 正文" }
forum 是 slug,也可以给 forum_id。走和网页完全相同的门:版块权限、字数下限、
烧饼为负、发帖间隔、版块自己的间隔、内容安全。成功 201:
{ "id": 1024, "url": "/t/1024/", "held": false, "message": "发帖成功:🫓 烧饼 +2,🚀 成长值 +2" }
held: true 表示被内容安全暂扣待审(message 说明),此时没有 url。message
是网页上那条 toast 的原文,直接显示。悬赏、投票、抽奖、红包这几种特殊帖不走
这个接口,打开网页的发帖页。从没发过帖、还没确认发帖提示的会员得到 403posting_notice_required,见下一节。
首次发帖提示(/api/v1/me/posting-notice/)
会员第一次发主题或回帖之前,要读一份发帖提示(站长 2026-10-25):四条规则,读满 30 秒
才能点「我已阅读,开始发帖」,没确认之前主题和回帖都发不出。内容、计时和判断都在服务端,
客户端只负责画弹窗——文案和网页弹窗是同一份(internal/web/firstpost.go 的firstPostText),不要在客户端里写死;计时用的是网页同一组 Redis 键,所以网页上开始、
App 里确认(或反过来)是同一份提示。发过帖的会员永远不会被要求;发过的帖全被删了的会再看到
一次。Redis 不可用时服务端放行(required: false、发帖不拒),客户端照常发帖即可。
流程:
发主题或回帖得到 403
posting_notice_required(或者客户端在打开编辑器时先问一次GET,required: true就先画弹窗)。错误体:{ "error": "posting_notice_required", "error_description": "发帖前请先阅读发帖提示", "posting_notice": "https://example.com/api/v1/me/posting-notice/" }GET /api/v1/me/posting-notice/取提示,画一个不能关闭、没有取消的弹窗(离开编辑器
就是离开)。弹窗真正出现在屏幕上时
POST /api/v1/me/posting-notice/start/,从返回的remaining
倒数,按钮在倒数结束前不可点,按钮文字后面显示剩余秒数(网页是「我已阅读,开始发帖(12)」)。倒数到 0,会员点按钮时
POST /api/v1/me/posting-notice/acknowledge/。200 就关掉弹窗。重发刚才那条主题或回帖(同一个
Idempotency-Key即可:拒绝不会被记住)。
GET /api/v1/me/posting-notice/
不需要提示时:
{ "required": false }
需要时:
{
"required": true,
"notice": {
"title": "发帖前,请先阅读以下提示",
"items": [
{ "heading": "选择合适的板块", "text": "请根据帖子内容选择对应板块;不确定时,可发布到水区。因板块选择错误被管理员移动的,将扣除 10 烧饼。" },
{ "heading": "遵守推广规则", "text": "…" },
{ "heading": "文明交流,避免刷屏", "text": "…" },
{ "heading": "请勿发布纯 AI 生成的内容", "text": "…" }
],
"rules": { "text": "详细规定请参阅《论坛规则》。", "link_text": "《论坛规则》",
"url": "https://example.com/pages/rules/#posting" },
"button": "我已阅读,开始发帖",
"note": "此提示仅在首次发帖前显示。",
"seconds": 30,
"remaining": null
}
}
items按顺序画成有序列表,heading加粗,text跟在后面。条数和文字以服务端为准;
扣烧饼那半句在没开积分或移动不扣费时不出现。rules.text是整句,把其中的link_text画成指向rules.url(绝对地址,网页)的链接。seconds是要读的总秒数;remaining是计时开始后还剩的秒数(向上取整,到 0 为止),
计时还没开始时是null——GET本身不开始计时,提前取、取两次都不会吃掉会员的
30 秒。
需要 api:read(或 api:write);官方应用照常。
POST /api/v1/me/posting-notice/start/
请求体为空({})。开始计时并返回剩余秒数;重复调用不会重新计时,返回的是第一次开始以来
还剩的(计时记录保留 24 小时):
{ "required": true, "remaining": 30 }
已经不需要提示(会员在别处发过帖或确认过)时 { "required": false },关掉弹窗即可。
POST /api/v1/me/posting-notice/acknowledge/
请求体为空({})。成功:
{ "required": false, "message": "可以开始发帖了" }
- 计时开始后不满 30 秒(服务端放宽 2 秒给网络延迟):429
too_soon,带Retry-After、retry_after和remaining(秒)。按remaining接着倒数。 - 没有开始过计时,或者开始已超过 24 小时:409
posting_notice_not_started。重新start、
重新倒数。 - 已经不需要提示:同成功的 200。
确认记录保留 30 天,确认之后会员发出第一帖就不会再被问到。start 和 acknowledge 是写入,
需要 api:write;会员自己的客户端有 api:write 就能带会员过这一关(§ 九)。
POST /api/v1/topics/{id}/replies/
{ "body": "…" }。回某一楼(进它的楼中楼框)时正文以「@名字 #楼层 」开头——网页的
「回复」按钮也是这样填的,服务端按它定 parent_no / root_no;不带就是直接回主题、
自成一楼。规则:正文里(代码块和行内代码除外)第一个「@ + 名字 + 至少一个半角
空格或 Tab + # + 楼层号」,楼层号比这条回复小的,就是它回的那一楼;#0 是楼主,不算
回某一楼。名字只要符合用户名规则(不含空格),不要求账号存在——回已注销作者的楼层写
「@火星人 #N」照样进那一楼的框。客户端填的就是被回复那一条的作者名和楼层号。
成功 201:id、no(楼层)、parent_no、root_no、topic_id、url、held、message(含复读、挖坟费、红包等所有提示),以及 reply——这条回复按帖子页同样的
形状:自成一楼时是带作者栏的楼层(带一个空的 box),进了框时是框里的一条(带 to),客户端直接插到root_no 那一楼的框里(root_no 为 0 就是新的一楼),不用重新拉帖子。被内容安全暂扣
(held: true)时没有 url 和 reply。关闭的帖、抽奖帖等拒绝时是 400 refused;
发帖间隔没到是 429 too_soon。
POST /api/v1/topics/{id}/vote/
{ "action": "up" | "down" }。响应 like_count、dislike_count、my_vote。
投过就不能改也不能撤,再投是 400。down 会按设置扣烧饼。
POST /api/v1/replies/{id}/light/
{ "action": "light" | "unlight" }。响应 light_count、my_light。规则同上。
POST /api/v1/topics/{id}/bookmark/
{ "on": true | false }。响应 bookmarked、bookmark_count。
POST /api/v1/replies/{id}/bookmark/
收藏一条回复:{ "on": true | false } → saved。
POST /api/v1/users/{uid}/follow/
{ "on": true | false }。响应 following、message。
POST /api/v1/notifications/read/
全部标已读。响应 { "unread": 0 }。
POST /api/v1/messages/{token}/
{ "content": "…", "reply_to": 123 }(reply_to 可选,必须是本会话里的消息)。
任一方拉黑时是 400 refused(「你们之间已拉黑」),对方已注销也是 400。
成功 201:这条消息,形状同会话里的 messages[] 一项(id、from_me: true、content、content_html、reply_to、created_at、read),外加 message,直接画成气泡。
POST /api/v1/checkin/
{ "message": "留言(可选)", "crit_card": false }。响应是签到状态加result: {fresh, streak, base, bonus, crit, crit_card, first, total};当天已经签过fresh 为 false,不重复发钱。网页上这个按钮前面有人机验证,接口上没有:
token 本来就是浏览器登录(过了验证)之后发给官方应用的,接口另有每会员的写入上限
(站长 2026-10-25 定)。所以这个接口只认官方应用:将来接口向会员自己的应用开放,签到也不在其中,
别的应用的 token 来签到是 403 client_not_first_party。
POST /api/v1/topics/{id}/delete/、POST /api/v1/replies/{id}/delete/
作者删自己的不用理由,{} 即可;版主删别人的必须带 { "reason": "…" },作者会收到
通知。删除进回收站,规则同网页(悬赏、投票、抽奖帖作者不能删,按站点设置)。
响应 { "deleted": true, "id": … }。
POST /api/v1/topics/{id}/tip/
{ "amount": 10 },amount 必须是 /site/ 的 limits.tip_tiers 之一;一个主题
只能打赏一次(帖子的 my_tip),不能打赏自己。成功 200:amount、tip_points、tip_count、points(打赏后余额)。打赏关着(features.tips 为 false)时是 400。
POST /api/v1/report/
{ "type": "topic" | "reply" | "user", "id": 1024, "reason": "spam", "detail": "可选,≤500 字" }
id 对帖子和回复是它们的 id,对会员是 UID。reason 取 /site/ 的 report_reasons
里的 value。同一个目标只能举报一次(再报是 400)。每会员每小时 10 次。成功 201:{ "id": …, "message": "…" }。
POST /api/v1/users/{uid}/block/
{ "on": true | false }。拉黑后双方互相看不到对方的帖子和回复:对方的主题对你 404、
楼层不出现(只留缺号)、列表的回复数和最后回复人也不算他;双方之间已有的关注(两个方向)
同时取消,之后互相关注是 400;不能私信(历史还能看)。解除拉黑不会恢复关注。响应blocked、message。
POST /api/v1/messages/{token}/pin/、POST /api/v1/messages/read/
置顶一个会话({ "on": true|false } → pinned);私信全部已读(→ unread_messages: 0)。
POST /api/v1/topics/{id}/edit/
{ "title": "…", "body": "…" }。作者和版主可以改;版块、回复顺序、访客可见这三项保持
原样(移动主题是网页的事)。规则和网页相同:改短到计分线以下会被拒。成功 200:id、url、message。
POST /api/v1/replies/{id}/edit/
{ "body": "…" }。抽奖回帖不能编辑。成功 200:id、topic_id、no、url、message。
POST /api/v1/upload/
multipart/form-data,文件字段名 file。和网页 /upload/file/ 同一套:类型按内容嗅探
(扩展名不算数)、大小上限(/site/ 的 limits.upload_max_bytes)、每小时次数和流量
上限、存储配额、每个文件收一次烧饼(/me/ 的 upload_cost,等级或身份够了是 0)、失败
退款。
成功 201:
{ "url": "https://…/x.png", "markdown": "", "image": true }
markdown 直接插进正文。站点没开上传时是 400 refused。
GET /api/v1/search/?q=&field=&forum=(需登录)
field 是 title(默认)、body、reply、user。q 不短于 /site/ 的limits.search_min_chars。每会员每小时 120 次。响应:title 和 body
给 topics(列表行的形状);reply 给命中回复所在的 topics,每行多一个match: {reply_id, excerpt, at, url};user 给 users。都带 next。
GET / POST /api/v1/me/push/
按类别的推送开关,和个性设置页同一份设置。GET 返回{ "enabled": true, "platforms": ["ios","android"], "kinds": [{"kind":"reply","label":"回复","on":true}, …] }。enabled 是服务端能不能推送(配了 APNs 或 FCM),platforms 是能推到哪些平台
(推送没开时是 []);开关是会员自己的,存着不受 enabled 影响。POST { "off": ["like","light"] } 把这几类关掉,没列的都是开;未知的 kind 400;响应同 GET。
类别:reply、mention、like、light、points、invite、system、direct。
POST /api/v1/preview/
{ "body": "Markdown", "topic_id": 1024 } → { "html": "…" }。按发到这个帖子
里的样子渲染(楼层引用按该帖解析)。
五、推送(APNs / FCM)
客户端用 expo-notifications 的 getDevicePushTokenAsync 拿原生令牌(不用
Expo 的推送服务),登录后登记:
POST /api/v1/devices/
{ "platform": "ios" | "android", "token": "…", "app_version": "1.0.0" } → 200{ "registered": true, "delivers": true }。delivers: false 说明服务端没配这个平台
的推送,登记了也不会收到。令牌是设备的:换人登录再登记一次,令牌就归新人。
每个会员最多登记 10 台设备,登记第 11 台时最久没有重新登记的那台被挤掉(不报错:
多出来的一台通常是新手机,最旧的是不用了的)。
登记跟着这一次登录:服务端记下请求所用 token 的登录(OIDC token family),这次登录
在论坛那边结束——退出、改密、退出所有设备、在「已授权的应用」里撤销、封禁、注销——之后
就不再推给这台设备。所以每次登录(拿到新的 refresh token)后都要重新登记一次。
POST /api/v1/devices/unregister/
{ "token": "…" }。退出登录或在客户端关掉通知时调。响应 { "registered": false }
(令牌不是你的也是这个回答)。
推送的内容
每条站内通知(回复、提及、支持、点亮、烧饼到账、系统通知、私信)发一条,会员在/me/push/ 或个性设置里关掉的类别不发:标题按类型
(「新回复」「有人提到你」「新私信」……),正文是不超过 80 字的摘要,私信只有
「<发件人> 发来一条私信」。badge 是未读通知加未读私信数。data:
| 字段 | 含义 |
|---|---|
kind | 通知类型 |
id | 通知 id |
path | 点开要打开的站内路径(/t/1024/?reply_id=9、/messages/<token>/、/u/6666/notifications/) |
topic_id / reply_id | 帖子和楼层(有则给) |
token | 私信会话的 token(私信才有) |
sender_uid | 发起人的 uid(有则给) |
同一个帖子的通知用同一个 collapse id(APNs thread-id / FCM collapse_key)叠在一起。
提供方说令牌失效时服务端会删掉登记。
六、WebView 换票
游戏、证券、彩票、商城这些页面第一版在 WebView 里打开,WebView 和系统浏览器
不共享 Cookie,所以要换一张票:
POST /api/v1/session/link/
{ "next": "/games/" }(可选,站内路径)→ 200
{ "url": "https://…/app/session/<票>/", "expires_in": 60,
"proof": "<一次性证明>", "proof_header": "X-BBS-App-Proof" }
WebView 打开这个 url,必须带请求头 X-BBS-App-Proof: <proof>(头名以 proof_header
为准):
- Android:
webView.loadUrl(url, mapOf("X-BBS-App-Proof" to proof))(additionalHttpHeaders)。 - iOS:
var req = URLRequest(url: url); req.setValue(proof, forHTTPHeaderField: "X-BBS-App-Proof"); webView.load(req)。 - React Native WebView:
source={{ uri: url, headers: { "X-BBS-App-Proof": proof } }}。
只在打开票的那一次请求上带,之后的跳转不用带。没带、带错(不是这张票的 proof)都是 403 页面。
页面收到正确的 proof 后,种一个 Cookie bbs_app_proof(HttpOnly、SameSite=Strict、Path=/app/session/、60 秒,HTTPS 站点上带 Secure),然后自动 POST 同一地址;POST 必须带着
这张票自己的 Cookie(别的票的 Cookie 不行),成功后 Cookie 被清掉、票换成会话 Cookie,再跳到next。客户端不用处理这个 Cookie,WebView 自己会带。proof 和票一样一分钟、只能用一次,
不要存、不要写日志。next 不是站内路径(带域名、// 开头……)时按 / 处理。
票一分钟内有效、只能用一次,改密或封禁之后作废。access token 本身不会进 WebView。
票是「一个账号装在地址里」,所以防登录 CSRF(安全审查 2026-10-25):
- 必须带上面的 proof 头。这是唯一能区分「App 自己打开」和「同一个地址被别人发到聊天、邮件、
别的 App 里点开」的东西:后者在浏览器眼里同样是Sec-Fetch-Site: none、没有 Referer,但带不了
请求头。 - 必须由 App 自己让 WebView 打开(
loadUrl/load(URLRequest),浏览器报Sec-Fetch-Site: none)。从别的网站点进来、跳过来的(cross-site/same-site)一律 403;
不发这个头的旧浏览器,带了别站的Referer也 403。页面自己提交的 POST 必须是same-origin。
不要在页面里用<a href>或 JS 跳转打开票,也不要把票地址交给系统浏览器。 - WebView 里已经登录了另一个账号时,页面不自动提交,而是写明「现在登录的是 A,继续会退出
它、改为登录 B」,点按钮才切换;已经是同一个账号就直接跳到next(票照样作废)。 - 访问日志和其它日志里票那一段记成
[redacted]。 - 换出来的网页会话跟着 App 的这次登录(OIDC token family):登录时间记的是 App 登录的时间,
不是换票的时间;App 退出、token 被吊销或过期之后,WebView 里的会话在下一次请求时一起结束。
六 b、WebView 打开的页面
这些页面第一版不画原生界面,用上面的票在 WebView 里打开。路径从 /site/ 的 pages
拿(管理员可能改前缀),不要写死:
pages. | 页面 |
|---|---|
profile / personal / privacy / security | 个人资料、个性设置、隐私、账号安全(改密、两步验证、通行密钥) |
account_close | 注销账号(商店审核要求 App 内能注销,入口放在设置里) |
verify_resend | 重发验证邮件(/me/ 的 email_verified 为 false 时提示) |
blocklist / attachments | 拉黑名单、我的附件 |
register / forgot | 注册、找回密码(系统浏览器打开,不用票) |
topic_new | 网页发帖页(悬赏、投票、抽奖、红包这几种特殊帖) |
shop / games / stock / lotto / bank / market | 商城、搏一搏、证券、彩票、银行、饼市 |
oidc_apps | 已授权的应用 |
docs 里是规则、条款、隐私、联系四个文档的地址;min_app_version 非空且比本机版本
新时,先提示升级。版本号是点分的数字(1.2.10),逐段按数字比较,缺的段算 0。
七、深链
论坛在 /.well-known/apple-app-site-association 和 /.well-known/assetlinks.json
声明官方应用(配置见 .env.example 的 BBS_APP_*),/app/callback/ 是登录回调。
客户端收到的站内路径(通知和推送的 path、各处的 url、正文里的链接)都是网页地址,按/site/ 的 url_scheme 解析,前缀管理员可以改,不要写死:
| 路径 | 打开 |
|---|---|
/<topic_prefix>/<id>/,开了 seo_slugs 时是 /<topic_prefix>/<id>-<slug>/ | 帖子(id 是第一个 - 之前的数字) |
上面再带 ?reply_id=<id> 或 ?reply=<楼层> | 帖子,定位到那条回复(GET /topics/{id}/ 带同样的参数) |
/<topic_prefix>/<id>/page/<n>/ | 帖子(页码由客户端自己按游标翻,忽略 n 即可) |
/<topic_prefix>/<id>/floor/<no>/ | 帖子里第 no 楼的楼中楼框(GET /topics/{id}/floors/{no}/) |
/<forum_prefix>/<slug>/ | 版块 |
/<user_prefix>/<uid>/ | 会员资料 |
/<user_prefix>/<uid>/notifications/ | 通知页(推送没有帖子可指时给的就是这个,uid 是自己) |
/messages/<token>/ | 和这个 token 的主人的私信会话 |
| 其它 | 用 WebView 打开(§ 六换票) |
八、测试
internal/web/api_e2e_test.go:真库真 Redis 的端到端(BBS_TEST_DATABASE_URL),
从 OIDC 授权码拿 token 起,把上面的接口走一遍。internal/web/api_member_e2e_test.go:会员自己的应用(§ 九)——官方专用的接口全部 403、
读写以本人身份、改删不了别人的、版主的应用没有管理权限、要扣烧饼的点踩被拒、只有api:read的写不了、开发者页面建出公开客户端和两个 scope。internal/web/api_v1_access_test.go:路由表的每一条都写明谁能调(apiWantAccess),
新加的接口不归类就编译通过、测试失败。scripts/smoke.sh第 26z1 节:对运行中的服务器做访客读取、认证拒绝和
well-known 文件的断言。
九、会员自己的客户端(站长 2026-10-25)
原先接口只认官方应用(first_party),而官方应用只有超级管理员能建,会员没有办法自己
写客户端。现在任何能在开发者页面注册应用的会员,都可以给自己的
应用申请三个 scope,用本文的接口写自己的客户端。这是同一套 OpenID 应用加三个 scope,
不是第二套认证:没有个人访问令牌,也没有第二张凭证表——撤销、封禁、改密、注销会让
token 失效的那些门,对它一样起作用。
怎么拿到
- 在「我的应用」创建应用(条件:邮箱已验证、注册满若干天、等级够,见开发者条款),
勾选「论坛接口」的读取(api:read)、写入(api:write,含读取)和 / 或
私信(api:messages,单独一项,见下)。手机 App、
桌面程序、浏览器页面选公开客户端(没有 secret,必须 PKCE);服务器端选机密客户端。 - 回调地址必须是 HTTPS,或
http://127.0.0.1:端口/…、http://localhost:端口/…(桌面
程序用本机回环地址,RFC 8252);不支持自定义 scheme,手机 App 用自己域名下的
Universal Link / App Link。 - 走授权码 + PKCE,请求 scope
openid profile api:write offline_access(只读的用api:read,要私信再加api:messages)。授权页会把每一项翻译成会员看得懂的话列出来。 - 每个请求带
Authorization: Bearer <access_token>。第三方应用的 access token 是
opaque 的(不是 JWT),15 分钟,用 refresh token 换。
未审核的应用只有开发者本人和他列的测试用户(最多 10 人)能授权——这正是「给自己写个
客户端」的情形,不用等审核。想让所有会员都能用,提交审核;审核员只能保留或去掉开发者
申请过的接口权限,不能替它加。已审核的应用不能自己再加接口权限(排队审核期间应用照常
可用,自己加上就等于没审核就生效),要加请管理员在后台加。
以谁的身份
token 只代表授权它的那一个会员,所有写入都落在这个会员名下,和他在网页上做的一样(同样
的发帖间隔、字数、版块权限、内容安全、禁言、烧饼为负不能发帖)。接口里没有任何参数能
换一个人来做。
自动发布(站长 2026-10-25):按服务条款,应用不得在会员没有逐次操作时替他发帖、回复、
投票、点亮——除非管理员在后台为这个应用打开了「允许自动发布」(oidc_clients.automation_allowed,
迁移 0278)。开关只有管理员能改,开发者页面只读显示;授权页和会员的「已授权的应用」会写明
「管理员已允许此应用自动发布」。接口本身不读它、不因它放宽任何限制(自动和手动在接口上分不出来),
它是许可的记录:没有它的应用被发现自动发布,按条款处理。
管理员、版主通过自己的应用登录时是普通会员:/me/ 的 can_manage 是 false,
帖子的 can_manage / can_edit 只对自己的内容为真,删改别人的内容是 403,也不再免禁言、
免上传费。管理操作留在网页和官方客户端,那里有处理原因和小黑屋公示。/me/ 不给 id
(数据库编号),会员一律用 uid。
能用哪些接口
| 接口 | 会员应用 | 需要的 scope | 说明 |
|---|---|---|---|
GET /site/、/cosmetics/、/topics/、/topics/{id}/、/topics/{id}/floors/{no}/ | 能 | api:read | 带 token 时按这个会员算可见性、拉黑 |
GET /users/{uid}/、/users/{uid}/followers/、/following/、/replies/ | 能 | api:read | |
GET /me/、/me/points/、/me/blocked/、/me/bookmarks/replies/ | 能 | api:read | /me/ 不含 id;烧饼余额、各个钱包(大米、毛豆、游戏币、馒头)和完整的烧饼明细都读得到,只读——授权页的「读取」一项照这样写明 |
GET /notifications/ | 能 | api:read | 通知列表本来就不含私信 |
GET /messages/、/messages/{token}/ | 能 | api:messages | 只认 api:messages,api:read / api:write 打不开;打开会话会把对方消息标已读,和网页一样 |
GET /search/、/users/mention/ | 能 | api:read | 每会员的搜索、补全限制照常 |
POST /topics/、/topics/{id}/replies/ | 能 | api:write | 回帖要付挖坟费时 403 paid_action,不扣钱 |
POST /topics/{id}/edit/、/replies/{id}/edit/、/topics/{id}/delete/、/replies/{id}/delete/ | 只限自己的 | api:write | 别人的是 403,版主也一样 |
POST /topics/{id}/vote/、/replies/{id}/light/ | 能 | api:write | 支持、点亮照常;要扣烧饼的反对、点灭 403 paid_action |
POST /topics/{id}/bookmark/、/replies/{id}/bookmark/、/users/{uid}/follow/、/users/{uid}/block/ | 能 | api:write | |
POST /report/ | 能 | api:write | 每会员每小时 10 次 |
POST /notifications/read/ | 能 | api:write | |
POST /messages/{token}/、/messages/{token}/pin/、/messages/read/ | 能 | api:messages | 私信的每会员限制照常;不需要 api:write |
POST /upload/ | 免费的才能 | api:write | 这个会员上传要扣烧饼时 403 paid_action(/me/ 的 upload_cost 非 0),失败不扣钱 |
POST /preview/ | 能 | api:write | |
GET /me/posting-notice/ | 能 | api:read | 首次发帖提示,见 § 四 |
POST /me/posting-notice/start/、/me/posting-notice/acknowledge/ | 能 | api:write | 有 api:write 的应用必须能带会员过这一关,否则发不出第一帖 |
GET / POST /checkin/ | 不能 | — | 签到只在网页和官方客户端(站长 2026-10-25) |
POST /topics/{id}/tip/ | 不能 | — | 打赏是会员之间转烧饼;帖子的 can_tip 对会员应用总是 false |
POST /session/link/ | 不能 | — | 换票会把 token 变成网页登录状态,等于把全部权限交出去 |
POST /devices/、/devices/unregister/、GET / POST /me/push/ | 不能 | — | 推送只能推到官方 App |
「不能」的接口回 403 client_not_first_party;scope 不够回 403 insufficient_scope。
这张表对没勾「官方客户端」的 first_party 应用同样适用(§ 一):它也是「会员应用」这一列,
版主、管理员用它登录也只是普通会员。
路由表在 internal/web/api_v1_access.go(apiRoutes),每一条都标了 apiVisitor /apiMember / apiMessages / apiOfficial;新接口必须在那里归类,并在这张表里写上。
私信是单独的权限(api:messages,站长 2026-10-25:「需要私信权限,以后还可以做成
聊天的形式」)。私信是论坛上唯一写给一个人看的东西:让工具替自己看帖,不等于让它读自己的
信;反过来,一个只做聊天的客户端也不该需要发帖的权限。所以:
- 没有
api:messages的会员应用,五个私信接口一律 403insufficient_scope,/me/不给unread_messages,/users/{uid}/不给message_token和can_message——字段直接
不出现,不是 0 或false。 - 有
api:messages的,读和发都能(读列表、读会话、发、标已读、置顶),不需要另外的api:read/api:write;只有api:messages的应用就只能碰私信。 - 官方客户端不受影响,照旧靠
forum全部可用。
不能花烧饼:会员授权的是「以我的身份操作」,从来不是「替我花钱」——扣烧饼的那扇门是points:spend,只有官方应用能持有(CLAUDE.md)。所以凡是规则要扣这个会员烧饼的动作,
会员应用一律拒绝而不是扣;商城、银行、彩票、游戏、证券本来就不在接口里。唯一的例外是
违规的罚款:复读(同样的话再发一遍)照规则扣 1 个烧饼,和网页上一样——那是罚款,不是消费。
限流
在每会员的两条之上(所有应用合计每小时 3000 次请求、300 次写入,官方应用也算在内),会员
应用另有:
| 范围 | 上限 |
|---|---|
| 一个应用 × 一个会员,读写合计 | 每小时 1200 次 |
| 一个应用 × 一个会员,写入 | 每小时 120 次 |
| 一个应用,全部会员合计 | 每小时 30000 次 |
超出是 429 rate_limited。第三条是全体会员共用的,为了不让几个账号替所有人把它用完
(安全审查 2026-10-25):被会员自己那两条拒掉的请求不算进去,一个会员每小时也只有前 300 次
算进去,超过的部分只受他自己那 1200 次管。数字在 internal/ratelimit(APIClient*),授权说明页和规则页
从那里读。用户多的已审核应用嫌第三条紧,找管理员。
浏览器里的客户端(CORS)
/api/v1/ 对所有来源开放 CORS(Access-Control-Allow-Origin: *,允许 Authorization、Content-Type、Idempotency-Key 头,预检 OPTIONS 回 204),不允许带 Cookie。这样开着
是安全的:接口从不读 Cookie,一个恶意网页能做的不会比它自己的访客更多。/oidc/token
本来就带 CORS,所以纯前端的应用可以自己走完 PKCE。
撤销
会员在已授权的应用里看得到每个应用、它的权限(「以你的身份
读取论坛」「以你的身份发帖……」)和上次使用时间,随时撤销单个或全部第三方应用;撤销后
access token 立即失效(opaque token 每次请求都查库)。改密、退出所有设备、封禁、注销同样
让它失效。