feat(api)!: fold devices into users and report member presence

Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.

- rename seats -> max_members, seats_used -> members_used,
  seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
  stream control message -> `members`, and POST /channels/{id}/seats ->
  POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
  endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
  is optional for logged-in accounts, no seat row is written for them,
  and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
  online_devices per account; presence is tracked in memory from active
  WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
This commit is contained in:
justme committed 2026-10-14 22:18:59 +08:00
1 parent 0fd2a269d9
commit d946fc5e8a
35 files changed
+517 -490

No files matched your search

+45 -77
View File
@@ -1,14 +1,15 @@
openapi: 3.1.0
info:
title: WebDrop API
version: 6.0.0
version: 7.0.0
servers:
- url: /api/v1
tags:
- name: server
- name: channel
- name: seat
- name: join
- name: guest
- name: frame
- name: item
- name: share
@@ -404,7 +405,7 @@ paths:
summary: 领取当前账号已加入频道的加密密钥包
responses:
'200':
description: 仅返回已加入频道,不占设备席位
description: 仅返回已加入频道
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/AccountChannel' } }
@@ -443,7 +444,7 @@ paths:
delete:
tags: [account]
security: [{ accountBearer: [] }]
summary: 撤销该设备全部 sessions 与频道席位,保留账号成员资格
summary: 撤销该设备的全部 sessions,保留账号成员资格
responses:
'204': { description: 已撤销 }
'401': { $ref: '#/components/responses/Error' }
@@ -702,7 +703,7 @@ paths:
get:
tags: [channel]
summary: 读取频道信息(不占成员名额)
description: 任意角色可调用。`seats_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
description: 任意角色可调用。`members_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -717,7 +718,7 @@ paths:
delete:
tags: [channel]
summary: 删除整个频道及全部密文(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
description: 有效的 owner 凭据即可调用,不要求 owner 终端登记。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/RoleOwner' }
@@ -733,7 +734,7 @@ paths:
summary: 修改设置(所有者)
description: |
字段均可选,只修改提供的字段。`ttl_seconds` 的变更从现在起算。
成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端登记。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -752,35 +753,46 @@ paths:
'400': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
/channels/{id}/seats:
/channels/{id}/join:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
post:
tags: [seat]
summary: 登记当前设备并加入频道
tags: [join]
summary: 加入频道
description: |
幂等:同一终端重复调用返回同一设备席位,不重复登记。
同一账号的设备不限量,账号首次加入时才占一个成员名额;成员名额已满返回 403 `seats_full`。
未登录访客每个终端单独计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、设备席位/设备名等操作要求终端已入席,否则 403 `not_joined`。
幂等。登录账号只登记成员关系:同一账号的多台设备不增加人数,也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。
未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个终端单独计一个成员名额。成员名额已满返回 403 `members_full`。
读取频道信息和 owner 管理接口不占名额;访客访问频道内容前必须已加入,否则 403 `not_joined`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
password: { type: string, minLength: 8, maxLength: 256 }
responses:
'200':
description: 已入席
description: 已加入
content:
application/json:
schema: { $ref: '#/components/schemas/SeatJoined' }
schema: { $ref: '#/components/schemas/JoinResult' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/channels/{id}/guests:
get:
tags: [seat]
summary: 查看已加入设备列表(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
tags: [guest]
summary: 查看访客(所有者)
description: 只列未登录访客的终端。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
responses:
'200':
@@ -789,69 +801,25 @@ paths:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Seat' }
items: { $ref: '#/components/schemas/Guest' }
'403': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/me:
put:
tags: [seat]
summary: 终端上传自己的加密设备名
description: 请求体是用 `K_enc` 加密后的设备名字节,≤256 B。服务端不解析。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary, maxLength: 256 }
responses:
'204': { description: 已保存 }
'403': { $ref: '#/components/responses/Error' }
'413': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/{endpointHash}/label:
put:
tags: [seat]
summary: 所有者重命名设备
description: 请求体是设备名称(≤256 B);enc 模式以目标 endpoint hash 作为 AAD 加密,raw 模式为明文。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- name: endpointHash
in: path
required: true
description: 终端标识,即 `base64url(SHA-256(S))`
schema: { $ref: '#/components/schemas/Base64Url' }
- { $ref: '#/components/parameters/RoleOwner' }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary, maxLength: 256 }
responses:
'204': { description: 名称已更新 }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'413': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/{endpointHash}:
/channels/{id}/guests/{endpointHash}:
delete:
tags: [seat]
summary: 撤销单台设备(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用设备席位。被释放终端后续请求返回 `not_joined`;账号成员身份及人数名额保留;向订阅者推送 `{"t":"seats"}`。
tags: [guest]
summary: 移除访客(所有者)
description: 移除该访客终端并禁止它再加入;被移除终端后续请求返回 `not_joined`。账号成员改用 `DELETE /invitations/{username}`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- name: endpointHash
in: path
required: true
description: 终端标识,即 `base64url(SHA-256(S))`
description: 访客终端标识,即 `base64url(SHA-256(S))`
schema: { $ref: '#/components/schemas/Base64Url' }
- { $ref: '#/components/parameters/RoleOwner' }
responses:
'204': { description: 已释放 }
'204': { description: 已移除 }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
@@ -950,7 +918,7 @@ paths:
**服务端 → 客户端**
- 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"seats"}` 席位变更。
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员/访客变更。
客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。
上传不走 WebSocket,仍用 `POST /frames`。
@@ -1294,8 +1262,8 @@ components:
Endpoint:
name: X-WD-Endpoint
in: header
required: true
description: 终端密钥 `S` 的 base64url。每个频道每个终端各生成一个,仅存本地。
required: false
description: 终端密钥 `S` 的 base64url,仅存本地。只有未登录访客必填;登录账号以 `X-WD-Account` 会话鉴权,可省略。
schema: { $ref: '#/components/schemas/Base64Url' }
ShareEndpoint:
name: X-WD-Endpoint
@@ -1368,7 +1336,7 @@ components:
- unauthorized
- forbidden
- not_joined
- seats_full
- members_full
- mode_mismatch
- not_found
- conflict
@@ -1439,11 +1407,11 @@ components:
ChannelInfo:
$ref: '../contracts/api.schemas.json#/$defs/ChannelInfo'
SeatJoined:
$ref: '../contracts/api.schemas.json#/$defs/SeatJoined'
JoinResult:
$ref: '../contracts/api.schemas.json#/$defs/JoinResult'
Seat:
$ref: '../contracts/api.schemas.json#/$defs/SeatInfo'
Guest:
$ref: '../contracts/api.schemas.json#/$defs/GuestInfo'
AppendResult:
$ref: '../contracts/api.schemas.json#/$defs/AppendResult'