一、认证

登录在系统浏览器里完成,走论坛的 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 进来,只能用 § 九 表里标了「会员应用」的接口,其余的是 403
client_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含义
    400refused业务上拒绝(内容为空、太短、已投过票、烧饼不足……),说明在 error_description
    400invalid_request / invalid_cursor请求的形状不对
    401unauthorized / invalid_token没带 token,或 token 无效、过期、已吊销
    403forbidden没有这个权限(版块不许发、禁言、未验证邮箱……)
    403client_not_first_party / insufficient_scope / user_unavailable / account_closing / email_unverified见字面
    403posting_notice_required从没发过帖的会员还没读过发帖提示,主题和回帖都发不出;响应另带 posting_notice(提示接口的绝对地址),按「首次发帖提示」一节画弹窗
    403paid_action会员自己的应用做了一件按规则要扣烧饼的事(要扣烧饼的点踩、点灭、上传,要付挖坟费的回帖),被拒绝、没扣钱(§ 九)
    404not_found不存在,或对你不可见(拉黑、屏蔽的版块、注销中的账号)
    409posting_notice_not_started发帖提示的「我已阅读」在计时开始之前(或计时过期后)提交:先 start 再确认
    409request_in_flight同一个 Idempotency-Key 的上一次请求还没处理完,稍后用同一个键重试
    422idempotency_key_reused同一个 Idempotency-Key 配了不同的请求体:客户端的 bug
    415unsupported_media_type请求体不是 JSON
    429rate_limited限流:每地址每小时 3000 次,每会员每小时 300 次写入,外加每个动作自己的限制;会员自己的应用另有每应用的限制(§ 九)
    429too_soon发帖间隔还没到(全站的 limits.post_interval_seconds,或版块自己的 forums[].post_interval_seconds):响应带 Retry-After 头和 retry_after(秒),等这么久用同一个 Idempotency-Key 重试即可(429 不会被幂等键记住,任何拒绝都不会)
    503oidc_disabled / unavailable站点没开 OpenID,或服务暂不可用
    500server_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)会被记住;任何拒绝和错误都不记,重试会真的再做一次——被拒绝的请求什么也没做,
    再做一次是安全的,而且得到的是现在的答案(比如充值之后不再是「可用烧饼不足」)。
    上一次还在处理时(上传可能要好几分钟,处理期间一直算在处理)重试得到 409 request_in_flight;
    同一个键换了请求体是 422 idempotency_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 的原文,直接显示。悬赏、投票、抽奖、红包这几种特殊帖不走
这个接口,打开网页的发帖页。从没发过帖、还没确认发帖提示的会员得到 403
posting_notice_required,见下一节。

首次发帖提示(/api/v1/me/posting-notice/)

会员第一次发主题或回帖之前,要读一份发帖提示(站长 2026-10-25):四条规则,读满 30 秒
才能点「我已阅读,开始发帖」,没确认之前主题和回帖都发不出。内容、计时和判断都在服务端,
客户端只负责画弹窗——文案和网页弹窗是同一份(internal/web/firstpost.go 的
firstPostText),不要在客户端里写死;计时用的是网页同一组 Redis 键,所以网页上开始、
App 里确认(或反过来)是同一份提示。发过帖的会员永远不会被要求;发过的帖全被删了的会再看到
一次。Redis 不可用时服务端放行(required: false、发帖不拒),客户端照常发帖即可。

流程:

  1. 发主题或回帖得到 403 posting_notice_required(或者客户端在打开编辑器时先问一次
    GET,required: true 就先画弹窗)。错误体:

    { "error": "posting_notice_required", "error_description": "发帖前请先阅读发帖提示",
      "posting_notice": "https://example.com/api/v1/me/posting-notice/" }
    
  2. GET /api/v1/me/posting-notice/ 取提示,画一个不能关闭、没有取消的弹窗(离开编辑器
    就是离开)。

  3. 弹窗真正出现在屏幕上时 POST /api/v1/me/posting-notice/start/,从返回的 remaining
    倒数,按钮在倒数结束前不可点,按钮文字后面显示剩余秒数(网页是「我已阅读,开始发帖(12)」)。

  4. 倒数到 0,会员点按钮时 POST /api/v1/me/posting-notice/acknowledge/。200 就关掉弹窗。

  5. 重发刚才那条主题或回帖(同一个 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": "![x.png](https://…/x.png)", "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 失效的那些门,对它一样起作用。

怎么拿到

  1. 在「我的应用」创建应用(条件:邮箱已验证、注册满若干天、等级够,见开发者条款),
    勾选「论坛接口」的读取(api:read)、写入(api:write,含读取)和 / 或
    私信(api:messages,单独一项,见下)。手机 App、
    桌面程序、浏览器页面选公开客户端(没有 secret,必须 PKCE);服务器端选机密客户端。
  2. 回调地址必须是 HTTPS,或 http://127.0.0.1:端口/…、http://localhost:端口/…(桌面
    程序用本机回环地址,RFC 8252);不支持自定义 scheme,手机 App 用自己域名下的
    Universal Link / App Link。
  3. 走授权码 + PKCE,请求 scope openid profile api:write offline_access(只读的用
    api:read,要私信再加 api:messages)。授权页会把每一项翻译成会员看得懂的话列出来。
  4. 每个请求带 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 的会员应用,五个私信接口一律 403 insufficient_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 每次请求都查库)。改密、退出所有设备、封禁、注销同样
让它失效。