openapi: 3.0.3 info: title: Spark IM API version: 0.1.0 description: | 本规范描述 **HTTP REST**。即时消息长连接是另一套 **TCP Frame**(TLS + 23 字节头 + JSON Body),见文末「TCP 协议」。没有 Protobuf。 ## 环境与地址 公网只开放 `443`。Nginx 按 TLS SNI 分流:HTTP 证书打到 `sparkapi.wyrs365.com`,IM 证书打到 `sparkim.wyrs365.com`,对象存储(RustFS S3)打到 `sparkr.wyrs365.com`。进程本身只绑本机。 | 环境 | 用途 | HTTP Base URL | IM 长连接 | | --- | --- | --- | --- | | 生产 | App / 联调 | `https://sparkapi.wyrs365.com` | `sparkim.wyrs365.com:443`(TLS,SNI=`sparkim.wyrs365.com`) | | 本机开发 | 直连 Gin / gnet | `http://127.0.0.1:8080` | `127.0.0.1:8090`(明文,无 TLS) | Swagger 右上角 **Servers** 切换 HTTP 环境;Try it out 打的是 HTTP,不会建 IM 长连接。 文档: 1. HTTP API / Swagger:`/docs` 2. 枚举目录:`/docs/enums` 3. 错误码:`/docs/errors` --- ## 统一响应(HTTP 与 TCP Body 同一套) 所有业务响应 Body 都是: ```json {"code":"0","msg":"ok","request_id":"","data":{}} ``` | 字段 | 类型 | 规则 | | --- | --- | --- | | `code` | **字符串** | `"0"` 成功;其它失败。不要用数字 `0` 比较。全表见 [错误码](/docs/errors)。 | | `msg` | 字符串 | 给人看的文案,随请求语言变化(`zh-CN` / `en` / `zh-TW`)。成功一般为 `ok`。 | | `request_id` | 字符串 | 服务端生成,11 位 base62(`0-9A-Za-z`)。客户端不用传;从响应体读取。与 Header `X-Request-ID` 相同。 | | `data` | object / array | 成功时为业务对象;失败时固定 `{}`。字段缺失当空对象,不要当 `null`。 | **判定成功只看 `code === "0"`。** 不要只看 HTTP 状态码。 | 场景 | HTTP 状态 | envelope | | --- | --- | --- | | 业务成功 / 业务失败(参数、未登录、无权限等) | **200** | `code` 为 `"0"` 或 `"40001"` / `"40101"` / `"40301"` 等 | | `/readyz` 未就绪 | **503** | `code`=`"50301"`,`data` 仍有探活字段 | | panic / 未捕获 | 200 | `code`=`"50001"` | Snowflake(`uid` / `msg_id` / `conversation_id`)是 int64。Android/iOS/Java 用 `long`;JavaScript 不要用 `Number` 接,用字符串或 BigInt。 --- ## HTTP 请求:Header / Path / Query / Body ``` METHOD {base}{path}?{query} Header: 鉴权、语言、设备、追踪 Body: 仅 POST / PATCH 的 JSON(GET / DELETE 无 Body) ``` `Content-Type`:有 Body 时必须是 `application/json`。 ### Header(客户端可设) | Header | 放哪 | 必填 | 说明 | | --- | --- | --- | --- | | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、以及公开音乐读接口(`/v1/music/playlist`、`/v1/music/playlist/grouped`、`/v1/music/tracks/{id}`、`/v1/music/categories`、`/v1/music/settings`)**不要**带。 | | `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 | | `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 | | `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 | | `Accept-Language` | Header | 可选 | 无 `X-Language` 时使用。 | | `X-App-Version` | Header | 可选 | 客户端版本,写入请求上下文。 | | `Content-Type` | Header | 有 Body 时必填 | `application/json`。 | **不要**把 token、语言、`device_id`、`app_id` 塞进业务 JSON(登录 / 刷新除外:登录 Body 带 `app_id` 与物理指纹字段,刷新 Body 带 `refresh_token`)。 网关写入、客户端不要发:`X-Real-IP`、`X-Forwarded-For`(限流/审计用,见 C6)。 ### 响应 Header | Header | 说明 | | --- | --- | | `X-Request-ID` | 与响应体 `request_id` 相同,服务端生成,客户端不用传 | | `Content-Type` | `application/json; charset=utf-8` | ### Path / Query / Body | 位置 | 放什么 | 例子 | | --- | --- | --- | | Path | 资源 id | `/v1/friends/{uid}` 的 `uid`;`/v1/groups/{id}` 的群号;`/v1/messages/{id}` 的 MsgId | | Query | GET 筛选 / 分页 | `GET /v1/friends/lookup?q=`;`limit`;历史 `conversation_id`、`last_msg_id` | | Body | POST/PATCH 的业务字段 | 登录的 `email`/`verify_code`/`app_id`/`device_type`/`idfv` 或 `android_id`;发消息的 `content` | ### 调用顺序 1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。封控见 [docs/sms.md](../../../docs/sms.md)(IP 桶 + 同 target 60s 冷却,均 `42902`)。测试号 `test01@spark.com`~`test100@spark.com` 可跳过发码。 2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。**不要**传客户端自造 `device_id`;传 **`app_id`**、**`device_type`(1–6)** 与 **`idfv` / `android_id`**,由服务端生成 `device_id`。持久化响应里的 `device_id` 与 `app_id`。 3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`(= JWT `did`)、`X-App-ID`(= JWT `aid`)、`X-Language`。 4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer;新 token 保留相同 `did` / `aid`。 5. App 在线发消息、收推送、增量同步走 **TCP**,不要用 `POST /v1/messages` 当主路径。 请求示例(生产): ```http POST /v1/auth/login HTTP/1.1 Host: sparkapi.wyrs365.com Content-Type: application/json X-Language: zh-CN X-App-Version: 1.0.0 {"email":"test01@spark.com","verify_code":"000000","app_id":1,"device_type":1,"idfv":"00000000-0000-0000-0000-000000000007","device_name":"iPhone"} ``` ```http GET /v1/users/me HTTP/1.1 Host: sparkapi.wyrs365.com Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... X-Device-ID: 7 X-App-ID: 1 X-Language: zh-CN ``` ```http POST /v1/messages HTTP/1.1 Host: sparkapi.wyrs365.com Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... Content-Type: application/json X-Device-ID: 7 X-Language: zh-CN {"peer_id":1940000000000000001,"client_msg_id":"c-1","content":{"t":1,"text":{"body":"hi"}}} ``` 成功:`{"code":"0","msg":"ok","request_id":"...","data":{...}}`。失败:`{"code":"40101","msg":"未授权","request_id":"...","data":{}}`。 --- ## TCP 协议(生产 `sparkim.wyrs365.com:443`) 不是 WebSocket、不是 HTTP、不是 Protobuf。客户端对 `sparkim.wyrs365.com:443` 做 **TLS**(SNI 必须是该域名),握手完成后在 TLS 上写二进制 Frame。本机开发可直连 `127.0.0.1:8090` 明文。 心跳 30s 一次;空闲 120s 服务端断开。Frame 最大 64KB。媒体字节走 HTTP 上传,消息里只带 object key。 ### Frame(大端,固定 23 字节头) ``` offset 0 Magic uint16 0x5350(ASCII "SP") offset 2 Version uint8 1 offset 3 Command uint16 offset 5 Flags uint16 bit0=1 表示 Body 是 gzip offset 7 RequestID uint64 客户端追踪,可 0;响应原样带回 offset 15 SequenceID uint32 请求/响应配对;PUSH 推送用它做 PUSH_ACK offset 19 BodyLength uint32 Body 字节数(压缩后) offset 23 Body bytes UTF-8 JSON;Flags bit0 则先 gzip 再当 Body ``` 读包:先读 23 字节,按 `BodyLength` 再读 Body。`Magic` 不对或总长超过 64KB 应断开。 压缩:Body ≥ 512B 且 gzip 更小时可设 Flags bit0。未压缩 Flags=0。解压后仍不得超过 64KB。 ### Command
值名方向请求 Body响应 Command / Body
1AUTH客户端 → 服务端不是 envelope:{"token","device_id?","app_version","language"};device_id 可选但须等于 JWT did;不必传 app_id(以 JWT aid 为准)同 1,envelope,data={"user_id","device_id"}
2HEARTBEAT客户端 → 服务端空 Body同 2,envelope data={}
3GENERATE_MSG_ID客户端 → 服务端空同 3,data.msg_id
4SEND_MESSAGE客户端 → 服务端不是 envelope:与 HTTP POST /v1/messages 同一 JSON5 SEND_ACK,data={"msg_id","conversation_id","client_msg_id","server_time"}
5SEND_ACK服务端 → 客户端—仅作 4 的应答
6MESSAGE服务端 → 客户端—envelope,data 为消息 Item(msg_id / content / …)
7PUSH_ACK客户端 → 服务端空;SequenceID=推送帧的 SequenceID无业务响应
8READ客户端 → 服务端不是 envelope:conversation_id,以及 msg_id 或 last_msg_id(任一大于 0;两者都大于 0 时用 msg_id)同 8,envelope,code 为 "0",data={"conversation_id","msg_id"}。data.msg_id 是落库后的 last_read_msg_id(只增不减),并清该成员 Redis 未读
9RECALL客户端 → 服务端{"msg_id"}同 9,envelope
10SYNC_INBOX客户端 → 服务端{"mailbox_seq","dialog_score","limit"},均可 0同 10,data.dialogs / mailbox / 游标
12SYNC_MESSAGES客户端 → 服务端{"conversation_id","last_msg_id","limit"}同 12,data.items / has_more
14KICK服务端 → 客户端—同设备顶号,data.reason
15DELETE客户端 → 服务端{"msg_id"}同 15
除 AUTH 外,业务命令须先 AUTH,否则 `40102`。HEARTBEAT / PUSH_ACK 可以未登录。 **重要:** AUTH、SEND、SYNC、撤回/删除的 **请求 Body 是业务 JSON**,不要包 `{code,msg,request_id,data}`。服务端应答和 MESSAGE 推送的 Body 才是统一 envelope。失败时 Command 仍为请求 Command(发送失败不是 SEND_ACK),`code`≠`"0"`。 发送成功条件(C5):MySQL COMMIT **并且** 入队 `deliver:queue` 后才 SEND_ACK。未收到 ACK 用同一 `client_msg_id` 重试。发送端其它设备会收到 MESSAGE;本设备不回推。 上线增量:先 `SYNC_INBOX`(会话列表/未读 + 撤回删除邮箱),再对脏会话 `SYNC_MESSAGES`(`last_msg_id` 之后)。成功在线 MESSAGE 不进邮箱。
Java 示例(点击展开,生产域名) ```java import javax.net.ssl.*; import java.io.*; import java.net.*; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.List; public class SparkImClient { static final String API = "https://sparkapi.wyrs365.com"; static final String IM_HOST = "sparkim.wyrs365.com"; static final int IM_PORT = 443; static final int HDR = 23; public static void main(String[] args) throws Exception { String login = httpPost(API + "/v1/auth/login", "{\"email\":\"test01@spark.com\",\"verify_code\":\"000000\",\"app_id\":1,\"device_type\":2,\"android_id\":\"YOUR_ANDROID_ID\"}"); String token = jsonString(login, "access_token"); // 从 data.access_token 取出 SSLSocketFactory f = (SSLSocketFactory) SSLSocketFactory.getDefault(); SSLSocket s = (SSLSocket) f.createSocket(); SSLParameters p = s.getSSLParameters(); p.setServerNames(List.of(new SNIHostName(IM_HOST))); s.setSSLParameters(p); s.connect(new InetSocketAddress(IM_HOST, IM_PORT), 10_000); s.startHandshake(); DataOutputStream out = new DataOutputStream(s.getOutputStream()); DataInputStream in = new DataInputStream(s.getInputStream()); write(out, 1, 1, "{\"token\":\"" + token + "\",\"device_id\":7,\"app_version\":\"1.0.0\",\"language\":\"zh-CN\"}"); System.out.println("AUTH " + read(in)); write(out, 2, 2, ""); System.out.println("HB " + read(in)); write(out, 4, 3, "{\"peer_id\":1,\"client_msg_id\":\"c1\",\"content\":{\"t\":1,\"text\":{\"body\":\"hi\"}}}"); System.out.println("ACK " + read(in)); // Command=5 } static void write(DataOutputStream out, int cmd, int seq, String body) throws IOException { byte[] b = body.getBytes(StandardCharsets.UTF_8); ByteBuffer buf = ByteBuffer.allocate(HDR + b.length); buf.putShort((short) 0x5350).put((byte) 1).putShort((short) cmd).putShort((short) 0) .putLong(0).putInt(seq).putInt(b.length).put(b); out.write(buf.array()); out.flush(); } static String read(DataInputStream in) throws IOException { byte[] h = in.readNBytes(HDR); ByteBuffer buf = ByteBuffer.wrap(h); if (buf.getShort() != 0x5350) throw new IOException("magic"); buf.get(); int cmd = buf.getShort() & 0xffff; int flags = buf.getShort() & 0xffff; buf.getLong(); int seq = buf.getInt(); int n = buf.getInt(); byte[] body = in.readNBytes(n); if ((flags & 1) != 0) { try (java.util.zip.GZIPInputStream gz = new java.util.zip.GZIPInputStream(new ByteArrayInputStream(body))) { body = gz.readAllBytes(); } } return "cmd=" + cmd + " seq=" + seq + " " + new String(body, StandardCharsets.UTF_8); } static String httpPost(String url, String json) throws IOException { HttpURLConnection c = (HttpURLConnection) URI.create(url).toURL().openConnection(); c.setRequestMethod("POST"); c.setDoOutput(true); c.setRequestProperty("Content-Type", "application/json"); c.setRequestProperty("X-Language", "zh-CN"); try (OutputStream os = c.getOutputStream()) { os.write(json.getBytes(StandardCharsets.UTF_8)); } try (InputStream is = c.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } static String jsonString(String env, String key) { int i = env.indexOf("\"" + key + "\""); int a = env.indexOf('"', env.indexOf(':', i) + 1) + 1; return env.substring(a, env.indexOf('"', a)); } } ```
iOS (Swift) 示例(点击展开,生产域名) ```swift import Foundation import Network import Compression let api = URL(string: "https://sparkapi.wyrs365.com")! let imHost = NWEndpoint.Host("sparkim.wyrs365.com") let imPort = NWEndpoint.Port(443)! struct Envelope: Decodable { let code: String; let msg: String; let request_id: String } struct LoginEnv: Decodable { struct Data: Decodable { let access_token: String } let code: String; let data: Data } func login() throws -> String { var req = URLRequest(url: api.appendingPathComponent("/v1/auth/login")) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("zh-CN", forHTTPHeaderField: "X-Language") req.httpBody = """ {"email":"test01@spark.com","verify_code":"000000","app_id":1,"device_type":1,"idfv":"00000000-0000-0000-0000-000000000007","device_name":"iPhone"} """.data(using: .utf8) let data = try URLSession.shared.send(req) return try JSONDecoder().decode(LoginEnv.self, from: data).data.access_token } func frame(cmd: UInt16, seq: UInt32, body: Data) -> Data { var d = Data(capacity: 23 + body.count) func u16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xff)) } func u32(_ v: UInt32) { d.append(UInt8(v >> 24)); d.append(UInt8(v >> 16 & 0xff)) d.append(UInt8(v >> 8 & 0xff)); d.append(UInt8(v & 0xff)) } u16(0x5350); d.append(1); u16(cmd); u16(0) d.append(contentsOf: [UInt8](repeating: 0, count: 8)); u32(seq); u32(UInt32(body.count)); d.append(body) return d } // TLS:NWParameters.tls,SNI 随 Host 自动带 sparkim.wyrs365.com let conn = NWConnection(host: imHost, port: imPort, using: .tls) conn.start(queue: .main) // 连接 ready 后: // let token = try login() // let auth = #"{"token":"\#(token)","device_id":7,"app_version":"1.0.0","language":"zh-CN"}"# // conn.send(content: frame(cmd: 1, seq: 1, body: Data(auth.utf8)), completion: .idempotent) // 收包:先 23 字节,再 BodyLength;Flags & 1 则 gunzip。看 JSON code == "0"。 // HEARTBEAT cmd=2;SEND_MESSAGE cmd=4,应答 cmd=5;推送 cmd=6 后发 PUSH_ACK cmd=7。 ```
本机 TCP 把 Host/Port 换成 `127.0.0.1:8090`,并用 **明文 TCP**(不要 TLS)。HTTP 仍用 `http://127.0.0.1:8080`。 仓库可运行示例:`docs/clients/SparkImClient.java`、`docs/clients/SparkImClient.swift`。 --- ## 客户端页面跳转 这一节是当前 iOS 客户端的页面导航,不是新接口。不要据此发明或调用本规范未列出的 HTTP / TCP 命令。底部实际是四个 Tab:消息、广场、匹配、我的。「发现」是广场里的分段,不是第五个底栏。返回用侧滑或返回键,弹窗用关闭,大图和「我的」侧栏盖在当前页上。这是客户端导航,工作区后续改动可能使它过时。 主路径: ```mermaid flowchart TD start["启动"] -->|替换根| login["邮箱登录"] start -->|已登录 替换根| home["主页"] start -->|封禁中 替换根| ban["违规页"] login -->|push| code["邮箱验证码"] login -->|present| agree["用户协议 / 隐私政策"] login -->|push| fb["意见反馈"] login -->|同页切换| pwd["密码登录"] code -->|替换根| home pwd -->|替换根| home code -->|返回| login home -->|tab| msg["消息"] home -->|tab| square["广场"] home -->|tab| match["匹配"] home -->|tab| me["我的"] msg -->|push| single["单聊"] msg -->|push| group["群聊"] msg -->|push| sys["系统消息"] msg -->|push| contacts["通讯录"] msg -->|push| add["添加朋友"] single -->|push| detail["聊天详情"] single -->|覆盖层| preview["图片与视频大图"] single -->|push| video["视频播放"] group -->|push| gset["群设置"] contacts -->|push| profile["好友资料"] profile -->|push| single square -->|分段| follow["关注"] square -->|分段| discover["发现"] square -->|push| publish["发布动态"] follow -->|push| post["动态详情"] discover -->|push| post follow -->|push| homePage["广场个人主页"] discover -->|push| homePage match -->|资料已齐 tab| card["卡片匹配"] match -->|资料未齐 tab| fill["资料填写"] fill -->|push| card card -->|push| cardInfo["名片详情"] me -->|覆盖层| drawer["我的侧栏"] drawer -->|push| setting["设置"] me -->|push| myProfile["个人资料"] ``` 设置与账号: ```mermaid flowchart TD setting["设置"] -->|push| account["账号与安全"] setting -->|push| teen["青少年模式"] setting -->|push| myProfile["个人资料"] setting -->|push| notice["消息通知"] setting -->|push| chatSet["聊天"] setting -->|push| privacy["隐私"] setting -->|push| general["通用"] setting -->|push| help["帮助"] setting -->|push| feedback["意见反馈"] setting -->|push| about["关于我们"] account -->|push| bind["第三方账号绑定"] account -->|push| real["实名认证"] account -->|push| resetPwd["设置或重置登录密码"] account -->|push| secPwd["安全密码"] account -->|push| cancel["账号注销"] myProfile -->|present| avatarCrop["头像裁切"] myProfile -->|push| qr["我的二维码"] ``` 图片预览与编辑。编辑含涂鸦、贴图、文字、裁剪、马赛克。头像只有裁切。 ```mermaid flowchart TD chat["单聊或群聊"] -->|覆盖层| browser["图片与视频大图"] chat -->|push| player["视频播放"] chat -->|present| album["相册"] chat -->|push| albumPreview["相册预览"] browser -->|present| editor["图片编辑"] album -->|present| editor profile["个人资料"] -->|present| cropOnly["头像裁切"] editor -->|同页| doodle["涂鸦"] editor -->|同页| sticker["贴图"] editor -->|覆盖层| text["文字"] editor -->|同页| clip["裁剪"] editor -->|同页| mosaic["马赛克"] ``` 消息右上角还能进添加朋友、扫一扫、发起群聊和群发助手。系统消息进意见反馈。单聊详情进好友资料、查找记录、投诉和会话锁定。侧栏里「附近的人」和「我的钱包」当前点不到。语音通话在菜单里已被注释。这些同样只是客户端导航。 servers: - url: https://sparkapi.wyrs365.com description: 生产(IM / 音乐客户端) - url: http://127.0.0.1:8080 description: 本机开发 - url: https://popadmin.wyrs365.com description: popadmin(音乐管理 Admin API) - url: https://admin.fullstorynovel.com description: FullStoryNovel 小说 CMS / 公开 API - url: https://fullstorynovel.com description: FullStoryNovel 官网(公开 /api 同源时可切换) security: - bearerAuth: [] tags: - name: enums description: "[打开枚举页](/docs/enums)" externalDocs: description: 打开枚举页 url: /docs/enums - name: errors description: "[打开错误码页](/docs/errors)" externalDocs: description: 打开错误码页 url: /docs/errors - name: system - name: meta description: 客户端枚举 JSON。`GET /v1/meta/enums`,无需登录。 - name: client description: 客户端配置(协议/反馈/订阅) - name: auth - name: users - name: friends - name: groups - name: conversations - name: messages - name: files - name: favorites - name: moments - name: square description: | 多广场:路径 `/v1/{square_channel}/…`,channel=`square|squarenovel|squaresteps|squarehealth`。 分类 Tab:`GET …/categories`;Feed 用 query `category`(推荐/最新/热门/附近/搞笑/美女)。 - name: music description: | 运营音乐列表。客户端公开读(无需登录,V3 envelope): `GET /v1/music/playlist`(可带 `categoryId` / `requireMember`)、 `GET /v1/music/playlist/grouped`(分类分区嵌套曲目,对齐欢休 zone 列表)、 `GET /v1/music/tracks/{id}`(单曲详情)、 `GET /v1/music/categories`、`GET /v1/music/settings`。 playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。 - name: admin-music description: | popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。 曲目 CRUD、封面/背景/音频上传、分类 CRUD、曲目分类绑定(多对多)、全局试听秒数。 敏感词:`/api/admin/spark/sensitive*`(`/api/admin/pop/sensitive*` 别名);词库与命中审计、热重载。 Admin JWT;响应 `{success,message,data}`。Host 常用 `https://popadmin.wyrs365.com`。 - name: novels description: | FullStoryNovel 公开小说 API(App/官网)。生产基址见 servers 中 fullstorynovel。 响应多为直接 JSON(非 V3 envelope)。 - name: admin-novels description: | FullStoryNovel 小说 CMS(8095)。生产 `https://admin.fullstorynovel.com`。 CMS Bearer;或用 wyrs Admin JWT 调 `POST /api/admin/adopt-ops-session` 换取。 popadmin「小说内容管理」iframe 嵌入该后台。 paths: /healthz: get: tags: [system] security: [] summary: 进程探活 operationId: healthz responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "0" msg: ok data: status: ok /readyz: get: tags: [system] security: [] summary: 就绪探活(MySQL / Redis / 是否接流量) description: | 进程存活用 `/healthz`(始终 200)。 `/readyz` 在未接流量、依赖不可用或关停中返回 HTTP 503,`code=50301`。 Nginx / 编排应摘流看本接口,不要看 `/healthz`。 operationId: readyz responses: "200": description: 已就绪 content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "0" msg: ok data: status: ok accepting: true mysql: ok redis: ok taskpool: ok "503": description: 未就绪 content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "50301" msg: 服务不可用 data: status: not_ready accepting: false mysql: skipped redis: skipped taskpool: ok /docs: get: tags: [system] security: [] summary: HTTP API 文档页(Swagger UI) description: | 文档入口: 1. 本页 `/docs` — 接口(Servers 切换环境) 2. `/docs/enums` — 枚举,左侧目录可跳转 3. `/docs/errors` — 错误码(envelope.code) operationId: docsUI responses: "200": description: HTML content: text/html: schema: type: string /docs/enums: get: tags: [enums] security: [] summary: 枚举目录页 description: | 拉取 `GET /v1/meta/enums`,按集合列出 value / name / label。 左侧有序列表点击跳到对应集合(`#enum-{set}`,例如 `/docs/enums#enum-message_type`)。 错误码见 `/docs/errors`。 operationId: docsEnums responses: "200": description: HTML content: text/html: schema: type: string /docs/errors: get: tags: [errors] security: [] summary: 错误码页 description: | envelope.code 全表,与 HTTP/TCP 同一套。`code` 为字符串,`"0"` 成功。 左侧点击跳到对应码(`#code-40101`)。数据来自 `GET /v1/meta/enums` 的 `envelope_code`。 operationId: docsErrors responses: "200": description: HTML content: text/html: schema: type: string /openapi.yaml: get: tags: [system] security: [] summary: OpenAPI 规范原文 operationId: openAPISpec responses: "200": description: YAML content: application/yaml: schema: type: string /v1/meta/enums: get: tags: [meta] security: [] summary: 类型 / 状态枚举目录 description: | 客户端接入用。无需登录。`label` 随 `X-Language` / `Accept-Language` 变化。 `value` 与 JSON/TCP/SQL 落库值一致(数字集合为 number,字符串集合为 string)。 `name` 是稳定英文标识,不随语言变化。人读目录:`/docs/enums`(类型/状态)、`/docs/errors`(envelope.code)。 operationId: listEnums responses: "200": description: data 为 EnumCatalog content: application/json: schema: $ref: "#/components/schemas/EnumsEnvelope" /v1/sms/codes: post: tags: [sms] security: [] summary: 发送验证码 description: | Body:`purpose` 1=登录 2=注销;`target` 为邮箱或大陆 11 位手机号(服务端识别,无需 channel)。 **Header `X-Language` 必填**(不接受仅 Accept-Language),用于邮件主题/正文。 登录发码无需 Bearer;注销发码需 Bearer,且 `target` 须为当前绑定邮箱(不支持手机号注销发码)。 **封控(失败均为 envelope `42902`,HTTP 仍 200)** 1. **按客户端 IP**(`RealIP`,依赖网关 `X-Real-IP` / PROXY):令牌桶,速率 `rate_limit.auth_per_second`(默认 5/s),与 `POST /v1/auth/login`、`POST /v1/auth/token/refresh` **共用**同一 IP 桶。 2. **按 target**:同一「渠道 + purpose + 规范化 target」**60 秒内**只能成功触发一次发信/发短信;Redis 键 `sms:cd:*`(无 Redis 时为进程内内存)。 不同 purpose、不同邮箱/手机号互不影响。成功响应 `data.expires_in` 固定 **300**(码有效期秒数,与 60s 冷却无关)。 测试邮箱 `test01@spark.com`~`test100@spark.com` 不发信且不占用 target 冷却。详情 [docs/sms.md](../../../docs/sms.md)。 邮箱经 SMTP;手机走阿里云短信(按号段选运营商模板)。 operationId: sendVerificationCode parameters: - $ref: "#/components/parameters/X-Language" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SendVerificationCodeRequest" example: purpose: 1 target: ada@example.com responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/auth/login: post: tags: [auth] security: [] summary: 邮箱验证码登录并签发 JWT description: | 首次验证成功会创建用户(Snowflake uid、默认 spark_id、shortid 个人二维码票据,不含 uid)。 登录 UPSERT `user_devices` 与 `devices`,并追加 `user_login_logs`(含 app_id)。 返回 access_token 与 refresh_token;`device_id` 由服务端生成。测试账号任意验证码即可。 `device_type` 必填:整数 1–6(见 meta `device_type`);可选 `platform` 同枚举作别名。 **设备模型(Breaking,见 `docs/devices-client.md`)**: - Body **不要**再传客户端自造 `device_id`。 - **必填** `app_id`(整数,如 Spark IM=`1`)、`device_type`(整数 1–6,见 meta / `AppId` / `DeviceType`)。 - 物理指纹:ios/macos 必填 `idfv`;android/harmony 必填 `android_id`;windows/web 至少一个。 - 服务端按 `SHA256("{device_type}:{idfv|android_id}")` 派生 `device_id`(同机多 App **共用**同一 `device_id`)。 - JWT access/refresh 含 `sub`(user_id)、`did`(device_id)、**`aid`(app_id)**、`use`。 - 响应 `data` 含服务端 `device_id` 与 `app_id`;客户端须持久化后用于 Header / TCP。 - 可选 `platform`:与 `device_type` 同枚举,仅当未传 `device_type` 时作别名;可选 `device_name`。 首次验证成功会创建用户。测试账号任意验证码即可。 Header(可选):`X-Language` / `Accept-Language`、`X-App-Version`。不要带 `Authorization`。 Body:`email`、`verify_code`、`app_id`、`device_type`、`idfv`/`android_id`、可选 `device_name`。 operationId: login parameters: - $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/Accept-Language" - $ref: "#/components/parameters/X-App-Version" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LoginRequest" example: email: test01@spark.com verify_code: "000000" app_id: 1 device_type: 1 idfv: "00000000-0000-0000-0000-000000000007" device_name: iPhone responses: "200": description: 统一 envelope。成功时 data 为 LoginData;失败时 data 为 {} content: application/json: schema: $ref: "#/components/schemas/LoginEnvelope" examples: ok: value: code: "0" msg: ok data: access_token: eyJhbGciOiJIUzI1NiJ9... refresh_token: eyJhbGciOiJIUzI1NiJ9... expires_in: 604800 refresh_expires_in: 2592000 user_id: 1940000000000000000 device_id: 7 app_id: 1 spark_id: s1a2b3c0 unauthorized: value: code: "40101" msg: 未授权 data: {} invalid: value: code: "40001" msg: 请填写验证码 data: {} /v1/auth/password-login: post: tags: [auth] security: [] summary: 邮箱密码登录并签发 JWT description: | 与验证码登录相同的设备模型(`app_id` / `device_type` / `idfv|android_id`)。 需先通过 `POST /v1/auth/password` 用验证码设置密码;测试邮箱可任意密码。 按 RealIP 限流。不要带 Authorization。 operationId: passwordLogin parameters: - $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/Accept-Language" - $ref: "#/components/parameters/X-App-Version" requestBody: required: true content: application/json: schema: type: object required: [email, password, app_id] properties: email: { type: string, format: email } password: { type: string, minLength: 6, maxLength: 72 } app_id: { type: integer, minimum: 1 } device_type: { type: integer, minimum: 1, maximum: 6 } platform: { type: integer, minimum: 1, maximum: 6 } device_name: { type: string } idfv: { type: string } android_id: { type: string } example: email: test01@spark.com password: "123456" app_id: 1 device_type: 1 idfv: "00000000-0000-0000-0000-000000000007" responses: "200": description: 与登录相同的 LoginData content: application/json: schema: $ref: "#/components/schemas/LoginEnvelope" /v1/auth/password: post: tags: [auth] security: [] summary: 验证码设置/重置邮箱登录密码 description: | 校验邮箱验证码后写入 bcrypt 哈希,供 `password-login` 使用。 Body:`email`、`verify_code`、`password`(6–72)。不需要 Bearer。 operationId: setPassword requestBody: required: true content: application/json: schema: type: object required: [email, verify_code, password] properties: email: { type: string, format: email } verify_code: { type: string } password: { type: string, minLength: 6, maxLength: 72 } responses: "200": description: 统一 envelope,data 为空对象 content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/auth/token/refresh: post: tags: [auth] security: [] summary: 用 refresh_token 续期 description: | 校验 refresh_token 后签发新的 access/refresh,旧一对立即失效(jti denylist)。 新 token 对保留相同 **`did` / `aid`**(与首次登录 App 绑定)。 不需要 access Bearer。按 RealIP 限流。Body 仅 `refresh_token`。 operationId: refreshToken requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RefreshRequest" example: refresh_token: eyJhbGciOiJIUzI1NiJ9... responses: "200": description: 与登录相同的 LoginData content: application/json: schema: $ref: "#/components/schemas/LoginEnvelope" /v1/auth/logout: post: tags: [auth] summary: 退出当前设备会话 description: | 吊销当前 **`(user, device, app)`** 会话(默认 `triple`:各 App 独立 refresh,互不踢)。 若运维切到 `pair` 模式,同设备其它 App 的 refresh 可能被互踢(需产品公告)。 并踢掉同 `device_id` 的 TCP 连接。 operationId: logout responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/legal/agreements: get: tags: [client] security: [] summary: 隐私政策与用户协议 description: | 按 `X-Language` / `Accept-Language` 返回隐私政策与用户协议正文。 无库表数据时回退内置默认文案。公开接口。 operationId: legalAgreements parameters: - $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/Accept-Language" responses: "200": description: data.privacy / data.terms content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/feedback: post: tags: [client] security: [] summary: 提交意见反馈(登录可选) description: | 可选鉴权:带有效 `Authorization: Bearer` → `auth_type=login` 并写入 `user_id`; 无 token → `auth_type=guest`,Body 必填 `contact`(邮箱或手机)与 `content`。 兼容字段 `email`(游客未传 contact 时可用 email 作为 contact)。 operationId: submitFeedback parameters: - $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/X-Device-ID" requestBody: required: true content: application/json: schema: type: object required: [content] properties: content: { type: string, minLength: 2, maxLength: 2000 } contact: type: string description: 游客必填;邮箱或手机号 email: type: string format: email description: 兼容旧字段;游客可代替 contact app_version: { type: string } responses: "200": description: data 为反馈记录(含 id、auth_type、contact) content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/subscriptions/plans: get: tags: [client] security: [] summary: 订阅套餐引导配置 description: | 返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。 公开接口;无库数据时回退默认四档。 支持按 **包名 / applicationId** 筛选:`packageName`(或 `package_name`)Query,或 Header `X-Wyrs-Package`。 有该包精确配置则返回之;否则回退 `package_name` 为空的默认档。 operationId: subscriptionPlans parameters: - $ref: "#/components/parameters/X-Language" - name: packageName in: query required: false schema: { type: string } description: applicationId / Bundle ID / 包名;也可用 package_name 或 Header X-Wyrs-Package - name: X-Wyrs-Package in: header required: false schema: { type: string } description: 与 packageName 等价 responses: "200": description: data.plans content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/users/me: get: tags: [users] summary: 当前用户资料 description: | Header:`Authorization: Bearer `(必填),可选 `X-Device-ID`、`X-App-ID`、`X-Language`。无 Body。 `avatar` 是 object_key,可匿名 GET `https://sparkr.wyrs365.com/spark/{avatar}`。 operationId: getMe parameters: - $ref: "#/components/parameters/X-Device-ID" - $ref: "#/components/parameters/X-App-ID" - $ref: "#/components/parameters/X-Language" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "0" msg: ok data: uid: 1940000000000000000 spark_id: alice_ok nickname: test01 avatar: perm/avatar/20260917/3H8kPq2mN1Rabcde.png qrcode: "-NDveu-9Q" birthday: "" gender: 0 status: 1 last_login_time: 1726550000000 last_active_time: 1726550000000 create_time: 1726550000000 update_time: 1726550000000 patch: tags: [users] summary: 修改资料 description: 可改 spark_id(8-16 位,字母开头)、nickname、avatar、gender(0/1/2)、birthday(YYYY-MM-DD)。未出现的字段不改。已有超范围 spark_id 可继续使用,改号须符合新规则。 operationId: patchMe requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PatchMeRequest" example: nickname: Ada gender: 2 birthday: "1994-05-01" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [users] summary: 注销当前账号 description: | 吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。 非测试账号需先 `POST /v1/sms/codes`(`purpose=2`,`target` 为绑定邮箱)发码,再在本接口提交 `verify_code`(遵守发码封控,见 docs/sms.md)。 同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。 对方仍可见历史消息(不删 `messages`)。 operationId: deleteMe requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/DeleteAccountRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/lookup: get: tags: [friends] summary: 查找用户 description: | 查询参数 `q`:邮箱则按邮箱查;纯数字按 uid 查;其余先按 spark_id,未命中再按 qrcode。 二维码为 shortid 个人扫描票据,不含 uid。对方已拉黑当前用户时返回 40401。 operationId: lookupFriend parameters: - in: query name: q required: true schema: { type: string } description: 邮箱 / uid / Spark账号 / 二维码 example: test02@spark.com responses: "200": description: 统一 envelope,data 为好友卡片(含 relation:0无 1好友 2已拉黑) content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/requests: get: tags: [friends] summary: 收到的待处理好友申请 operationId: listFriendRequests parameters: - in: query name: limit schema: { type: integer, default: 20, maximum: 100 } responses: "200": description: data.items content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [friends] summary: 发起好友申请 description: | 通过扫描二维码、搜索邮箱或 spark_id 发起。可填申请理由与备注。 同一对用户只保留一条申请,重复发起覆盖理由/备注,不新增行。 若对方已有待处理申请指向自己,则自动成为双向好友并写入一条系统消息。 不支持单向好友。 operationId: applyFriend requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FriendApplyRequest" example: email: test02@spark.com reason: 我是同事 remark: 同事 responses: "200": description: data 见 FriendApplyData。status 为 1 待处理或 2 已同意(与 friend_requests.status 同值,不是字符串) content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/requests/reject: post: tags: [friends] summary: 拒绝/忽略好友申请 description: | 仅申请接收方可操作。将 pending 申请标为 rejected(status=3)。 已拒绝则幂等成功;非 pending 返回 403。 operationId: rejectFriendRequest requestBody: required: true content: application/json: schema: type: object required: [request_id] properties: request_id: { type: integer, format: int64 } example: request_id: 1940000000000000100 responses: "200": description: 统一 envelope,data 为空对象 content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends: get: tags: [friends] summary: 好友列表 operationId: listFriends parameters: - in: query name: limit schema: { type: integer, default: 20, maximum: 100 } responses: "200": description: data.items,置顶好友排在前面;每项含 remark、blocked、pinned content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [friends] summary: 同意好友申请 description: 成为双向好友后自动在单聊中插入一条 `kind=friend.became` 系统消息并入队 deliver。 operationId: acceptFriend requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FriendAcceptRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/{uid}: get: tags: [friends] summary: 查看好友资料 operationId: getFriend parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [friends] summary: 删除好友 description: 解除双向关系,删除双方各一条好友边。 operationId: deleteFriend parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/{uid}/remark: put: tags: [friends] summary: 设置好友备注 operationId: setFriendRemark parameters: - $ref: "#/components/parameters/FriendUID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FriendRemarkRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/{uid}/pin: put: tags: [friends] summary: 置顶好友会话 description: 仅写入自己这一侧的好友边 is_pinned,不影响对方。 operationId: pinFriend parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [friends] summary: 取消置顶 operationId: unpinFriend parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/{uid}/block: put: tags: [friends] summary: 拉黑 description: | 单向拉黑,写在自己这一侧的好友边上,不解除好友关系。 任一侧拉黑后双方都不能发消息。被拉黑一方再申请时视为用户不存在。 operationId: blockFriend parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [friends] summary: 取消拉黑 operationId: unblockFriend parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/friends/{uid}/reports: post: tags: [friends] summary: 投诉好友 operationId: reportFriend parameters: - $ref: "#/components/parameters/FriendUID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FriendReportRequest" example: reason: 1 detail: spam responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/groups: get: tags: [groups] summary: 我加入的群 operationId: listGroups parameters: - in: query name: limit schema: { type: integer, default: 20, maximum: 100 } responses: "200": description: data.items,id 为自增群号,conversation_id 为 Snowflake 会话 id content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [groups] summary: 创建群 description: | `id` 为表内自增群号。发消息、同步使用返回的 `conversation_id`(Snowflake)。 只能拉好友入群。群主无法直接退群,需先转让。 建群成功后服务端按实际人数拼接成员已有头像(注册默认或自定义),最多 9 人;两人就是并排两格,不会凑九宫格。 成员头像拉不到时用字母占位图,整张拼图失败则改用群名默认头像,`avatar` 不会为空。 写入 `perm/group/{群号}/`。之后可用 `purpose=group_avatar` 直传自定义头像再 PATCH。 operationId: createGroup requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateGroupRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/groups/{id}: get: tags: [groups] summary: 群资料 operationId: getGroup parameters: - $ref: "#/components/parameters/GroupID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" patch: tags: [groups] summary: 修改群名称 / 头像 / 公告 / 全员禁言 description: 仅群主和管理员。`announcement` 为空字符串表示清空公告。 operationId: updateGroup parameters: - $ref: "#/components/parameters/GroupID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateGroupRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [groups] summary: 解散群(仅群主) operationId: dissolveGroup parameters: - $ref: "#/components/parameters/GroupID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/groups/{id}/members: get: tags: [groups] summary: 群成员 operationId: listGroupMembers parameters: - $ref: "#/components/parameters/GroupID" - in: query name: limit schema: { type: integer, default: 200, maximum: 200 } responses: "200": description: data.items content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [groups] summary: 邀请好友入群 operationId: inviteGroupMembers parameters: - $ref: "#/components/parameters/GroupID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InviteGroupMembersRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/groups/{id}/members/{uid}: patch: tags: [groups] summary: 设管理员、禁言、群昵称、群备注 description: | `role` 仅群主可改(2=管理员 3=普通成员)。`speak_muted` 群主/管理员可对成员设置;管理员不能禁言其他管理员。 `nickname` 是我在群里的昵称(自己可改;管理员也可改他人)。`remark` 是群备注,仅自己可改。 operationId: updateGroupMember parameters: - $ref: "#/components/parameters/GroupID" - $ref: "#/components/parameters/FriendUID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateGroupMemberRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [groups] summary: 踢人,或 uid 为自己时退群 operationId: removeGroupMember parameters: - $ref: "#/components/parameters/GroupID" - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/groups/{id}/transfer: post: tags: [groups] summary: 转让群主 operationId: transferGroup parameters: - $ref: "#/components/parameters/GroupID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TransferGroupRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/files: post: tags: [files] summary: 签发对象存储直传凭证 description: | 客户端只传 `purpose` / `mime` / `size`,服务端生成 object_key 并签发短时 Presigned PUT。 文件直传 RustFS,不经过 Gin。 `avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid,匿名 GET); `group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET); 聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。 朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。 广场:`square_image` / `square_video` 写入 `perm/square/{image|video}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖时删对象)。 `{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。 发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。 服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。 客户端 PUT 必须原样带返回的 `headers`。消息只存 object_key。 operationId: createUploadCredential requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateUploadRequest" example: purpose: chat_image mime: image/jpeg size: 245760 responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "0" msg: ok data: object_key: chat/image/20260917/3H8kPq2mN1Rabcde.jpg upload_url: https://sparkr.wyrs365.com/spark/chat/image/20260917/3H8kPq2mN1Rabcde.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256 method: PUT headers: Content-Type: image/jpeg Content-Length: "245760" If-None-Match: "*" expires_in: 900 /v1/files/batch: post: tags: [files] summary: 批量签发直传凭证 description: | 一次返回 1~3 个 Presigned PUT,顺序与 `items` 相同。聊天图片请按「缩略图、压缩大图、原图」排列;未勾选原图只传前两项。 mime 仅 `image/jpeg` / `image/png`(HEIC、WebP 请客户端先转)。限流按条数计入 `upload_per_second`。文件仍直传 RustFS。 operationId: createUploadCredentials requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateUploadBatchRequest" example: items: - purpose: chat_image mime: image/jpeg size: 8192 - purpose: chat_image mime: image/jpeg size: 245760 - purpose: chat_image mime: image/jpeg size: 3500000 responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "0" msg: ok data: items: - object_key: chat/image/20260917/3H8kPq2mN1Rabcde.jpg method: PUT headers: Content-Type: image/jpeg Content-Length: "8192" If-None-Match: "*" - object_key: chat/image/20260917/4kPq2mN1RabcdeXY.jpg method: PUT - object_key: chat/image/20260917/5mN1RabcdeXY12ab.jpg method: PUT /v1/files/downloads: post: tags: [files] summary: 签发对象存储下载凭证 description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。九宫格请用 `POST /v1/files/downloads/batch`。 operationId: createDownloadCredential requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateDownloadRequest" example: object_key: chat/20260917/1/2.jpg responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/files/downloads/batch: post: tags: [files] summary: 批量签发下载凭证 description: | 一次返回 1~9 个 Presigned GET,顺序与 `object_keys` 相同。朋友圈九宫格一次拿齐。 限流按请求计入,不按条数。文件仍直传 RustFS GET。 operationId: createDownloadCredentials requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateDownloadBatchRequest" example: object_keys: - perm/moment/image/20260918/a.jpg - perm/moment/image/20260918/b.jpg responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" example: code: "0" msg: ok data: items: - object_key: perm/moment/image/20260918/a.jpg download_url: https://sparkr.wyrs365.com/spark/perm/moment/image/20260918/a.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256 method: GET expires_in: 900 - object_key: perm/moment/image/20260918/b.jpg method: GET /v1/favorites: get: tags: [favorites] summary: 收藏列表 operationId: listFavorites parameters: - in: query name: limit schema: type: integer default: 20 maximum: 100 responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [favorites] summary: 收藏一条消息 description: | 会话成员可将消息收入收藏。图片/语音/视频/文件会 CopyObject 到 `perm/fav/{yyyyMMdd}/{name}.{ext}`。 源对象已从 `chat/` 过期删除则失败(`code=40402`),不提供本地补传。 同一 `msg_id` 重复收藏返回已有记录。撤回不影响已收藏快照。 operationId: createFavorite requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateFavoriteRequest" example: msg_id: 1 responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/favorites/{id}: delete: tags: [favorites] summary: 取消收藏 operationId: deleteFavorite parameters: - $ref: "#/components/parameters/FavoriteID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments: get: tags: [moments] summary: 好友朋友圈 Feed description: | `cursor` 为上一页最后一条 `post_id`,0 表示最新。读时按好友、拉黑、不看他/不让他看、可见范围过滤。 不写会话 inbox。 operationId: listMomentFeed parameters: - in: query name: cursor schema: { type: integer, format: int64, default: 0 } - in: query name: limit schema: { type: integer, default: 20, maximum: 50 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [moments] summary: 发朋友圈 description: | 纯文,或最多 9 张图,或 1 条视频(图/视频互斥)。媒体先 `POST /v1/files`(`moment_image` / `moment_video`)再填 key。 浏览九宫格:`POST /v1/files/downloads/batch` 一次换 9 张 Presigned GET。 `visibility`:0 好友公开 1 私密 2 白名单 3 黑名单。2/3 需 `audience_uids`(好友,最多 200)。 operationId: publishMoment requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PublishMomentRequest" example: text: hello visibility: 0 responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/unread: get: tags: [moments] summary: 发现页红点 operationId: momentUnread responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/read: put: tags: [moments] summary: 更新发现页已读水位 operationId: momentMarkRead requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MomentReadRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/notices: get: tags: [moments] summary: 赞评通知列表 operationId: listMomentNotices parameters: - in: query name: cursor schema: { type: integer, format: int64, default: 0 } - in: query name: limit schema: { type: integer, default: 20, maximum: 50 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/notices/read: post: tags: [moments] summary: 通知标已读 operationId: readMomentNotices requestBody: content: application/json: schema: $ref: "#/components/schemas/MomentNoticeReadRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/settings: get: tags: [moments] summary: 朋友圈设置 operationId: getMomentSettings responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" patch: tags: [moments] summary: 改封面或可见天数 operationId: patchMomentSettings requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PatchMomentSettingsRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/settings/hides/{uid}: put: tags: [moments] summary: 不看他 / 不让他看 operationId: putMomentHide parameters: - $ref: "#/components/parameters/FriendUID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MomentHideRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [moments] summary: 清除对该好友的隐藏 operationId: deleteMomentHide parameters: - $ref: "#/components/parameters/FriendUID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/users/{uid}: get: tags: [moments] summary: 某人朋友圈相册 description: 非好友返回 404。看自己不过可见天数,能看到私密帖。 operationId: listMomentAlbum parameters: - $ref: "#/components/parameters/FriendUID" - in: query name: cursor schema: { type: integer, format: int64, default: 0 } - in: query name: limit schema: { type: integer, default: 20, maximum: 50 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/{id}: get: tags: [moments] summary: 单条动态 operationId: getMoment parameters: - $ref: "#/components/parameters/MomentID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [moments] summary: 删除自己的动态 operationId: deleteMoment parameters: - $ref: "#/components/parameters/MomentID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/{id}/like: put: tags: [moments] summary: 点赞 operationId: likeMoment parameters: - $ref: "#/components/parameters/MomentID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [moments] summary: 取消赞 operationId: unlikeMoment parameters: - $ref: "#/components/parameters/MomentID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/{id}/comments: post: tags: [moments] summary: 评论或回复 operationId: commentMoment parameters: - $ref: "#/components/parameters/MomentID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MomentCommentRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/moments/{id}/comments/{cid}: delete: tags: [moments] summary: 删除评论 operationId: deleteMomentComment parameters: - $ref: "#/components/parameters/MomentID" - name: cid in: path required: true schema: { type: integer, format: int64 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/categories: get: tags: [square] summary: 广场分类 Tab description: | 返回筛选分类:`recommend`/`latest`/`hot`/`nearby`(feed 模式)与 `funny`/`beauty`(内容标签)。 各广场实例共用同一套分类枚举;数据按 `square_channel` 隔离。 operationId: listSquareCategories parameters: - $ref: "#/components/parameters/SquareChannel" responses: "200": description: 统一 envelope;data.items[].value / kind(feed|tag) content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/posts: get: tags: [square] summary: 广场 Feed description: | `square_channel`:`square`(原路径兼容)/ `squarenovel` / `squaresteps` / `squarehealth`。 `category`:推荐/最新/热门/附近为排序模式;搞笑/美女按帖标签筛选。 `nearby` 可传 `lat`/`lng`(曼哈顿距离 stub);未传则仅返回带 POI 的帖并按 id 倒序。 `cursor` 为上一页最后一条 `post_id`,0 表示最新。 operationId: listSquareFeed parameters: - $ref: "#/components/parameters/SquareChannel" - in: query name: category schema: $ref: "#/components/schemas/SquareCategory" description: 缺省 recommend - in: query name: cursor schema: { type: integer, format: int64, default: 0 } - in: query name: limit schema: { type: integer, default: 20, maximum: 50 } - in: query name: lat schema: { type: number, format: double } description: 附近排序纬度(category=nearby) - in: query name: lng schema: { type: number, format: double } description: 附近排序经度(category=nearby) responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [square] summary: 发广场帖 description: | 内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。 `visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。 可选 `category` 内容标签:`funny` / `beauty`(不可传 feed 模式值)。 operationId: publishSquarePost parameters: - $ref: "#/components/parameters/SquareChannel" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PublishSquareRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/posts/{id}: get: tags: [square] summary: 单条广场帖 operationId: getSquarePost parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/SquarePostID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [square] summary: 删除自己的广场帖 operationId: deleteSquarePost parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/SquarePostID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/posts/{id}/like: put: tags: [square] summary: 点赞 operationId: likeSquarePost parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/SquarePostID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" delete: tags: [square] summary: 取消赞 operationId: unlikeSquarePost parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/SquarePostID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/posts/{id}/comments: post: tags: [square] summary: 评论或回复 operationId: commentSquarePost parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/SquarePostID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MomentCommentRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/posts/{id}/comments/{cid}: delete: tags: [square] summary: 删除评论 operationId: deleteSquareComment parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/SquarePostID" - name: cid in: path required: true schema: { type: integer, format: int64 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/users/{uid}/posts: get: tags: [square] summary: 某用户广场帖 description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。仅返回该 channel 下的帖。 operationId: listSquareUserPosts parameters: - $ref: "#/components/parameters/SquareChannel" - $ref: "#/components/parameters/FriendUID" - in: query name: cursor schema: { type: integer, format: int64, default: 0 } - in: query name: limit schema: { type: integer, default: 20, maximum: 50 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/notices: get: tags: [square] summary: 赞评通知列表 operationId: listSquareNotices parameters: - $ref: "#/components/parameters/SquareChannel" - in: query name: cursor schema: { type: integer, format: int64, default: 0 } - in: query name: limit schema: { type: integer, default: 20, maximum: 50 } responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/notices/unread: get: tags: [square] summary: 广场互动未读角标 operationId: squareNoticeUnread parameters: - $ref: "#/components/parameters/SquareChannel" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/{square_channel}/notices/read: post: tags: [square] summary: 通知标已读 operationId: readSquareNotices parameters: - $ref: "#/components/parameters/SquareChannel" requestBody: content: application/json: schema: $ref: "#/components/schemas/MomentNoticeReadRequest" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/music/playlist: get: tags: [music] summary: 音乐列表(公开) description: | 返回已上架曲目、启用中的分类树与全局「会员音乐免费试听秒数」。无需登录。 封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。 客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。 传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。 传 `requireMember=true|false` 可仅返回会员曲或免费曲(对齐欢休 VIP 分区筛选)。 operationId: getMusicPlaylist security: [] parameters: - name: categoryId in: query required: false schema: { type: integer, format: int64, minimum: 0 } description: 按分类筛选;0 或不传=全部上架曲目 - name: requireMember in: query required: false schema: { type: boolean } description: true=仅会员曲;false=仅免费曲;不传=全部 responses: "200": description: 统一 envelope,`data` 为 MusicPlaylist content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: $ref: "#/components/schemas/MusicPlaylist" /v1/music/playlist/grouped: get: tags: [music] summary: 分区嵌套音乐列表(公开) description: | 按叶子分类分组返回曲目,便于首页「分区 + 分区下音乐」一次拉取。 对齐欢休 `zoneAndZoneMusicList` 语义(Spark 用 category 作 zone)。 `parentId`>0 时:该分类为叶子则只返回自身;有子分类则返回其子叶子。 无需登录。 operationId: getMusicPlaylistGrouped security: [] parameters: - name: parentId in: query required: false schema: { type: integer, format: int64, minimum: 0 } description: 父分类 id;0 或不传=全部叶子分类 responses: "200": description: 统一 envelope,`data` 为 MusicGroupedPlaylist content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: $ref: "#/components/schemas/MusicGroupedPlaylist" /v1/music/tracks/{id}: get: tags: [music] summary: 单曲详情(公开) description: | 返回已上架曲目详情(含播放 `audioUrl`、封面/背景)。下架或不存在返回 `40401`。 对齐欢休 `/music/v1/detail`;无需登录。 operationId: getMusicTrack security: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64, minimum: 1 } responses: "200": description: 统一 envelope,`data` 为 MusicTrack content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: $ref: "#/components/schemas/MusicTrack" /v1/music/categories: get: tags: [music] summary: 音乐分类树(公开) description: 返回启用中的总分类与子分类树。无需登录。 operationId: getMusicCategories security: [] responses: "200": description: 统一 envelope,`data.categories` 为 MusicCategory 数组(含 children) content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object properties: categories: type: array items: $ref: "#/components/schemas/MusicCategory" /v1/music/settings: get: tags: [music] summary: 音乐全局配置(公开) description: 仅返回 `memberPreviewSeconds`(会员曲免费试听秒数)。无需登录。 operationId: getMusicSettings security: [] responses: "200": description: 统一 envelope content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: $ref: "#/components/schemas/MusicSettings" /api/admin/spark/music: get: tags: [admin-music] summary: 管理端曲目列表 description: | popadmin 同源调用。`data.items` 为全部曲目(含下架与 `categoryIds`),`data.settings` 为全局试听配置,`data.categories` 为分类树。 别名:`GET /api/admin/pop/music`。 operationId: adminListMusic security: - bearerAuth: [] responses: "200": description: "AdminApiResponse:success、message、data(含 items 与 settings)" content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" post: tags: [admin-music] summary: 新建曲目 operationId: adminCreateMusic security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MusicUpsertRequest" responses: "200": description: data 为 MusicTrack(AdminApiResponse) content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/settings: get: tags: [admin-music] summary: 读取全局试听时长 operationId: adminGetMusicSettings security: - bearerAuth: [] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" put: tags: [admin-music] summary: 更新全局试听时长 description: Body `{"memberPreviewSeconds":0..3600}` operationId: adminPutMusicSettings security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MusicSettings" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/{id}: patch: tags: [admin-music] summary: 更新曲目元数据(曲名/会员/排序/上下架/分类) description: | 可改 `title`、`requireMember`、`sortOrder`、`enabled`、`categoryIds`。 封面/背景/音频请分别调用 POST `/music/{id}/cover|background|audio` 上传或替换。 operationId: adminUpdateMusic security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MusicUpsertRequest" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" delete: tags: [admin-music] summary: 删除曲目(并尝试删除对象存储文件) operationId: adminDeleteMusic security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/categories: get: tags: [admin-music] summary: 分类树(含下架) description: "`data.categories` 为总分类树(children 为子分类)。别名 `/api/admin/pop/music/categories`。" operationId: adminListMusicCategories security: - bearerAuth: [] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" post: tags: [admin-music] summary: 新建总分类或子分类 description: | Body:`name` 必填;`parentId` 空/0=总分类,非空=挂到该总分类下(仅两级); `sortOrder`、`enabled` 可选。 operationId: adminCreateMusicCategory security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MusicCategoryUpsertRequest" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/categories/{id}: patch: tags: [admin-music] summary: 更新分类(改名/排序/启用/父级) operationId: adminUpdateMusicCategory security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MusicCategoryUpsertRequest" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" delete: tags: [admin-music] summary: 删除分类(级联删子分类与曲目绑定) operationId: adminDeleteMusicCategory security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/{id}/categories: put: tags: [admin-music] summary: 设置曲目所属分类(多对多全量替换) description: Body `{"categoryIds":[1,2,3]}`;空数组清除全部归属。亦可在 PATCH `/music/{id}` 传 `categoryIds`。 operationId: adminSetMusicTrackCategories security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: type: object required: [categoryIds] properties: categoryIds: type: array items: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/{id}/cover: post: tags: [admin-music] summary: 上传封面(multipart file) description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/cover/...`。 operationId: adminUploadMusicCover security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/{id}/background: post: tags: [admin-music] summary: 上传背景图(multipart file) description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/background/...`。可空(未上传时 `backgroundUrl` 为空串)。 operationId: adminUploadMusicBackground security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/music/{id}/audio: post: tags: [admin-music] summary: 上传 mp3(multipart file) description: 字段名 `file`;audio/mpeg,≤50MB。写入 RustFS `perm/music/audio/...`。 operationId: adminUploadMusicAudio security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/sensitive/words: get: tags: [admin-music] summary: 敏感词分页列表 description: | Query:`q`(匹配 word_raw/word)、`category`(1–9)、`enabled`(`true`/`false`/`1`/`0`)、`page`、`size`。 别名:`GET /api/admin/pop/sensitive/words`。 operationId: adminListSensitiveWords security: - bearerAuth: [] parameters: - name: q in: query schema: { type: string } - name: category in: query schema: { $ref: "#/components/schemas/SensitiveCategory" } - name: enabled in: query schema: { type: string } description: true/false 或 1/0 - name: page in: query schema: { type: integer, default: 0 } - name: size in: query schema: { type: integer, default: 50, maximum: 200 } responses: "200": description: "`data.items` 为词条,`data.total` 为总数" content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" post: tags: [admin-music] summary: 新建敏感词 description: | `word` 为运营录入原文;服务端 Fold 后写入 `word` 列(唯一键)。`action=4`(hide)在 v1 会按 reject 处理。 成功后自动 Reload 自动机。 operationId: adminCreateSensitiveWord security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SensitiveWordCreateRequest" responses: "200": description: 返回新建词条 content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/sensitive/reload: post: tags: [admin-music] summary: 从数据库重建敏感词自动机 operationId: adminReloadSensitiveWords security: - bearerAuth: [] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/sensitive/words/{id}: patch: tags: [admin-music] summary: 更新敏感词 operationId: adminPatchSensitiveWord security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SensitiveWordPatchRequest" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" delete: tags: [admin-music] summary: 下线敏感词(enabled=0) operationId: adminDeleteSensitiveWord security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" /api/admin/spark/sensitive/hits: get: tags: [admin-music] summary: 敏感词命中审计分页 description: Query:`uid`、`category`、`since`/`until`(RFC3339)、`page`、`size`。 operationId: adminListSensitiveHits security: - bearerAuth: [] parameters: - name: uid in: query schema: { type: integer, format: int64 } - name: category in: query schema: { $ref: "#/components/schemas/SensitiveCategory" } - name: since in: query schema: { type: string, format: date-time } - name: until in: query schema: { type: string, format: date-time } - name: page in: query schema: { type: integer, default: 0 } - name: size in: query schema: { type: integer, default: 50, maximum: 200 } responses: "200": description: "`data.items` 为命中行,`data.total` 为总数" content: application/json: schema: $ref: "#/components/schemas/AdminApiResponse" # —— FullStoryNovel 公开小说 API(基址选 admin.fullstorynovel.com 或 fullstorynovel.com)—— /api/home: get: tags: [novels] summary: 首页聚合(精选/热门/新书/更新/分类) operationId: fsnHome servers: - url: https://admin.fullstorynovel.com - url: https://fullstorynovel.com security: [] responses: "200": description: HomeDto 直接 JSON content: application/json: schema: $ref: "#/components/schemas/FsnHome" /api/genres: get: tags: [novels] summary: 分类列表 operationId: fsnGenres servers: - url: https://admin.fullstorynovel.com - url: https://fullstorynovel.com security: [] parameters: - name: tree in: query schema: { type: boolean } description: true 时返回一级含 children 树 responses: "200": description: OK content: application/json: schema: type: array items: $ref: "#/components/schemas/FsnGenre" /api/novels: get: tags: [novels] summary: 小说分页搜索 operationId: fsnListNovels servers: - url: https://admin.fullstorynovel.com - url: https://fullstorynovel.com security: [] parameters: - { name: status, in: query, schema: { type: string } } - { name: language, in: query, schema: { type: string } } - { name: genre, in: query, schema: { type: string }, description: 一级或二级 slug } - { name: q, in: query, schema: { type: string } } - { name: sort, in: query, schema: { type: string, default: popular } } - { name: page, in: query, schema: { type: integer, default: 0 } } - { name: size, in: query, schema: { type: integer, default: 24 } } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnNovelPage" /api/novels/{slug}: get: tags: [novels] summary: 小说详情 operationId: fsnGetNovel servers: - url: https://admin.fullstorynovel.com - url: https://fullstorynovel.com security: [] parameters: - name: slug in: path required: true schema: { type: string } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnNovelCard" /api/novels/{slug}/chapters: get: tags: [novels] summary: 章节目录 operationId: fsnListChapters servers: - url: https://admin.fullstorynovel.com - url: https://fullstorynovel.com security: [] parameters: - name: slug in: path required: true schema: { type: string } responses: "200": description: OK content: application/json: schema: type: array items: $ref: "#/components/schemas/FsnChapterMeta" /api/novels/{novelSlug}/chapters/{chapterSlug}: get: tags: [novels] summary: 章节正文 operationId: fsnGetChapter servers: - url: https://admin.fullstorynovel.com - url: https://fullstorynovel.com security: [] parameters: - { name: novelSlug, in: path, required: true, schema: { type: string } } - { name: chapterSlug, in: path, required: true, schema: { type: string } } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnChapterDetail" # —— FullStoryNovel CMS 管理 API(基址 admin.fullstorynovel.com)—— /api/admin/adopt-ops-session: post: tags: [admin-novels] summary: 用 wyrs Admin JWT 换取 CMS 会话 description: | Header `Authorization: Bearer `(与 popadmin 登录同一 JWT)。 返回 CMS `token`;popadmin iframe 嵌入时用 postMessage 注入。 operationId: fsnAdoptOpsSession servers: - url: https://admin.fullstorynovel.com security: [] responses: "200": description: "CMS JSON:token、username、opsToken 等" content: application/json: schema: type: object properties: token: { type: string, description: FSN CMS Bearer } username: { type: string } opsToken: { type: string } expiresInHours: { type: integer } /api/admin/stats: get: tags: [admin-novels] summary: CMS 仪表盘计数 operationId: fsnAdminStats servers: - url: https://admin.fullstorynovel.com responses: "200": description: OK content: application/json: schema: type: object properties: novels: { type: integer, format: int64 } publishedNovels: { type: integer, format: int64 } chapters: { type: integer, format: int64 } genres: { type: integer, format: int64 } /api/admin/novels: get: tags: [admin-novels] summary: 管理端小说列表 operationId: fsnAdminListNovels servers: - url: https://admin.fullstorynovel.com responses: "200": description: OK content: application/json: schema: type: array items: $ref: "#/components/schemas/FsnAdminNovelCard" post: tags: [admin-novels] summary: 新建小说 operationId: fsnAdminCreateNovel servers: - url: https://admin.fullstorynovel.com requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FsnNovelUpsert" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnAdminNovelCard" /api/admin/novels/{id}: get: tags: [admin-novels] summary: 管理端小说详情 operationId: fsnAdminGetNovel servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnAdminNovelCard" put: tags: [admin-novels] summary: 更新小说(含上下架 published / 精选 featured) operationId: fsnAdminUpdateNovel servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FsnNovelUpsert" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnAdminNovelCard" delete: tags: [admin-novels] summary: 删除小说及章节 operationId: fsnAdminDeleteNovel servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: type: object properties: ok: { type: boolean } /api/admin/covers: post: tags: [admin-novels] summary: 上传封面 multipart operationId: fsnAdminUploadCover servers: - url: https://admin.fullstorynovel.com requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: { type: string, format: binary } responses: "200": description: 含 coverUrl content: application/json: schema: type: object properties: coverUrl: { type: string } /api/admin/novels/{id}/chapters: get: tags: [admin-novels] summary: 管理端章节列表 operationId: fsnAdminListChapters servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: type: array items: $ref: "#/components/schemas/FsnChapterMeta" post: tags: [admin-novels] summary: 新增章节 operationId: fsnAdminCreateChapter servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FsnChapterUpsert" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnChapterMeta" /api/admin/chapters/{id}: get: tags: [admin-novels] summary: 章节详情(含正文) operationId: fsnAdminGetChapter servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: Chapter 实体 JSON put: tags: [admin-novels] summary: 更新章节 operationId: fsnAdminUpdateChapter servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FsnChapterUpsert" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnChapterMeta" delete: tags: [admin-novels] summary: 删除章节 operationId: fsnAdminDeleteChapter servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: type: object properties: ok: { type: boolean } /api/admin/genres: get: tags: [admin-novels] summary: 管理端分类/标签 operationId: fsnAdminListGenres servers: - url: https://admin.fullstorynovel.com responses: "200": description: OK content: application/json: schema: type: array items: $ref: "#/components/schemas/FsnAdminGenre" post: tags: [admin-novels] summary: 新建分类或二级标签 operationId: fsnAdminCreateGenre servers: - url: https://admin.fullstorynovel.com requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FsnGenreUpsert" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnAdminGenre" /api/admin/genres/{id}: put: tags: [admin-novels] summary: 更新分类 operationId: fsnAdminUpdateGenre servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FsnGenreUpsert" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/FsnAdminGenre" delete: tags: [admin-novels] summary: 删除分类 operationId: fsnAdminDeleteGenre servers: - url: https://admin.fullstorynovel.com parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: "200": description: OK content: application/json: schema: type: object properties: ok: { type: boolean } /v1/conversations: get: tags: [conversations] summary: 会话列表增量(离线后上线) description: | 与 TCP `SYNC_INBOX` 同一 Application。`dialog_score` 是 Redis 会话 ZSET 游标(score=MsgId), `mailbox_seq` 是撤回/删除等控制事件游标。成功在线推送的新消息不进 mailbox,正文用 `GET /v1/messages?last_msg_id=` 或 `SYNC_MESSAGES` 按 MsgId 补齐。 Header:`Authorization`(必填),可选 `X-Device-ID`、`X-Language`。 Query:`dialog_score`、`mailbox_seq`、`limit`。无 Body。 operationId: syncInbox parameters: - $ref: "#/components/parameters/X-Device-ID" - $ref: "#/components/parameters/X-Language" - in: query name: dialog_score required: false schema: type: integer format: int64 default: 0 - in: query name: mailbox_seq required: false schema: type: integer format: int64 default: 0 - in: query name: limit required: false schema: type: integer default: 200 maximum: 200 responses: "200": description: data 含 dialogs / mailbox / dialog_score / mailbox_seq / has_more content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/messages: get: tags: [messages] summary: 会话历史 description: | `id > last_msg_id` 升序,最多 200 条,`has_more` 表示还有更新消息。 私聊读 `private_message`(含 `receiver_id`),群聊读 `group_message`(含 `group_id` 群号)。 不含当前用户已删除的消息;已撤回的消息 `status=2` 仍返回。 长连接优先 `SYNC_MESSAGES`。 Header:`Authorization`(必填),可选 `X-Device-ID`、`X-Language`。 Query:`conversation_id`、`last_msg_id`、`limit`。无 Body。 operationId: listMessages parameters: - $ref: "#/components/parameters/X-Device-ID" - $ref: "#/components/parameters/X-Language" - in: query name: conversation_id required: true schema: type: integer format: int64 - in: query name: last_msg_id required: false schema: type: integer format: int64 default: 0 - in: query name: limit required: false schema: type: integer default: 200 maximum: 200 responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" post: tags: [messages] summary: 发消息(调试/非长连接;App 在线发送用 TCP) description: | **客户端在线发消息走 TCP `SEND_MESSAGE`(Command=4)**,不是本接口。对方实时收消息走 TCP `MESSAGE` 推送。 本接口与 TCP 调用同一套 Application(落库 + deliver 入队),给调试、单测、尚未接 TCP 的页面用。HTTP 发完不会变成长连接,也收不到实时推送。 `conversation_id` 与 `peer_id` 至少填一个。单聊写入 `private_message`,群聊写入 `group_message`。 回复在 `content.reply_to`。群聊 `content.at_uids` 可@成员;`content.at_all` 仅群主/管理员可@所有人。 成功 data 含 `msg_id`、`conversation_id`、`client_msg_id`、`server_time`。 ACK 仅在 MySQL COMMIT 且 deliver 入队成功后返回(与 TCP `SEND_ACK` 同一条件)。 Header:`Authorization: Bearer `(必填)、可选 `X-Device-ID`、`X-Language`、`X-App-Version`。 Body(JSON):`conversation_id` 或 `peer_id`、`client_msg_id`、`content`。token / device_id / 语言不放 Body。 operationId: sendMessage parameters: - $ref: "#/components/parameters/X-Device-ID" - $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/X-App-Version" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SendMessageRequest" example: peer_id: 2 client_msg_id: "c-1" content: t: 1 reply_to: 0 text: body: hello responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/messages/{id}/recall: post: tags: [messages] summary: 撤回消息 description: 仅发送者、2 分钟内。全员可见 `status=2`,经 deliver 下发 `RECALL_MESSAGE`。 operationId: recallMessage parameters: - $ref: "#/components/parameters/MessageID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" /v1/messages/{id}: delete: tags: [messages] summary: 删除消息(仅自己) description: 写入 `message_deletes`,对端历史不变。其他设备经 `DELETE_MESSAGE` 同步隐藏。 operationId: deleteMessage parameters: - $ref: "#/components/parameters/MessageID" responses: "200": description: 统一 envelope content: application/json: schema: $ref: "#/components/schemas/Envelope" components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: | HTTP Header:`Authorization: Bearer `。 `access_token` 来自 `POST /v1/auth/login` 的 `data.access_token`。TCP AUTH Body 的 `token` 用同一值(不是 Header)。 schemas: Gender: type: integer enum: [0, 1, 2] description: users.gender。0未知 1男 2女 UserStatus: type: integer enum: [0, 1] description: users.status。0停用 1正常 ConversationType: type: integer enum: [1, 2] description: conversations.type。1单聊 2群聊 MessageKind: type: integer enum: [1, 2] description: 消息分表,与 ConversationType 同值。1=private_message 2=group_message MessageType: type: integer enum: [1, 2, 3, 4, 5, 6, 7, 8, 9] description: messages.message_type / content.t。1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统 MessageStatus: type: integer enum: [1, 2, 3] description: private_message/group_message.status。1正常 2已撤回 3审核隐藏 RelationKind: type: integer enum: [0, 1, 2] description: 查看者视角 JSON relation(不是 friend_relations.status)。0无 1好友 2已拉黑 FriendRequestStatus: type: integer enum: [1, 2, 3] description: friend_requests.status,申请结果 status 同此。1待处理 2已同意 3已拒绝 FriendSource: type: integer enum: [0, 1, 2, 3] description: 好友添加来源。0未知 1二维码 2邮箱 3spark_id ReportReason: type: integer enum: [1, 2, 3, 4] description: friend_reports.reason。1垃圾 2骚扰 3不实身份 4其他 GroupStatus: type: integer enum: [1, 2] description: groups.status。1正常 2已解散 GroupRole: type: integer enum: [1, 2, 3] description: group_members.role。1群主 2管理员 3成员。PATCH 成员只能设 2 或 3 MailboxEventType: type: integer format: int16 enum: [1, 2, 3] description: mailbox_events.event_type。1漏推消息 2撤回 3删除 DeviceType: type: integer format: int32 enum: [1, 2, 3, 4, 5, 6] description: | 设备类型,见 meta `device_type`。1=ios 2=android 3=harmony 4=windows 5=macos 6=web。 指纹 payload 使用该数值。 AppId: type: integer enum: [1] description: | 登录 App 标识。写入 JWT `aid`。当前文档约定 Spark IM=`1`(以 `GET /v1/meta/enums` → `app_id` 为准;实现落地后枚举会扩展)。 Language: type: string enum: [zh-CN, en, zh-TW] description: 缺省 zh-CN UploadPurpose: type: string enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover, square_image, square_video] SystemKind: type: string enum: [friend.became, system.welcome, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer] description: content.system.kind,服务端系统消息 MomentKind: type: integer enum: [1, 2, 3] description: moment_posts.kind。1纯文 2图 3视频 MomentVisibility: type: integer enum: [0, 1, 2, 3] description: moment_posts.visibility。0好友公开 1私密 2白名单 3黑名单 MomentStatus: type: integer enum: [0, 1] description: moment_posts.status。0正常 1已删 MomentNoticeKind: type: integer enum: [1, 2, 3] description: moment_notices.kind。1赞 2评论 3回复 MomentVisibleDays: type: integer enum: [0, 3, 30, 180] description: moment_settings.visible_days。0全部 3/30/180 天 SquareChannel: type: string enum: [square, squarenovel, squaresteps, squarehealth] description: 广场实例路径段;与 t_square_posts.channel 一致 SquareCategory: type: string enum: [recommend, latest, hot, nearby, funny, beauty] description: | Feed 筛选。recommend/latest/hot/nearby 为排序模式;funny/beauty 为帖标签(发帖可写)。 PublishSquareRequest: allOf: - $ref: "#/components/schemas/PublishMomentRequest" - type: object properties: category: $ref: "#/components/schemas/SquareCategory" description: 仅 funny/beauty;发帖不可传 feed 模式值 PublishMomentRequest: type: object properties: text: type: string maxLength: 1000 images: type: array maxItems: 9 items: $ref: "#/components/schemas/MomentImage" video: $ref: "#/components/schemas/MomentVideo" visibility: $ref: "#/components/schemas/MomentVisibility" audience_uids: type: array maxItems: 200 items: type: integer format: int64 poi: $ref: "#/components/schemas/MomentPOI" MomentImage: type: object required: [key] properties: key: { type: string } mime: { type: string } w: { type: integer } h: { type: integer } size: { type: integer, format: int64 } thumb_key: { type: string } orig_key: { type: string } MomentVideo: type: object required: [key, duration_ms] properties: key: { type: string } mime: { type: string } w: { type: integer } h: { type: integer } duration_ms: { type: integer, maximum: 30000 } size: { type: integer, format: int64 } cover_key: { type: string } MomentPOI: type: object properties: lat: { type: number, format: double } lng: { type: number, format: double } name: { type: string } address: { type: string } MomentReadRequest: type: object required: [post_id] properties: post_id: { type: integer, format: int64 } MomentNoticeReadRequest: type: object properties: cursor: { type: integer, format: int64 } PatchMomentSettingsRequest: type: object properties: cover_key: { type: string } visible_days: $ref: "#/components/schemas/MomentVisibleDays" MomentHideRequest: type: object properties: hide_him: { type: boolean } hide_me: { type: boolean } MomentCommentRequest: type: object required: [text] properties: text: { type: string, maxLength: 500 } reply_to_id: { type: integer, format: int64 } TcpCommand: type: integer enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] description: | Frame.Command:1 AUTH 2 HEARTBEAT 3 GENERATE_MSG_ID 4 SEND_MESSAGE 5 SEND_ACK 6 MESSAGE 7 PUSH_ACK 8 READ 9 RECALL 10 SYNC_INBOX 11 SYNC_INBOX_FINISH 12 SYNC_MESSAGES 13 SYNC_MESSAGES_FINISH 14 KICK 15 DELETE_MESSAGE。 命令 8 READ 请求体不是 envelope:`conversation_id`,以及 `msg_id` 或 `last_msg_id` (任一大于 0;两者都大于 0 时用 `msg_id`)。成功应答命令仍为 8, Body 为 Envelope,`code` 为 `"0"`,`data` 为 `{"conversation_id","msg_id"}`,其中 `msg_id` 是 `t_conversation_members.last_read_msg_id`(只增不减)。服务端清该用户 Redis `im:dialog:unreadcnt` 字段,并把会话从 `im:dialog:unread` 挪到 `im:dialog:read`。 水位前进时通知该用户其他在线设备(命令 8,不系统推送、不写 mailbox)。 缺 `conversation_id`,或两个消息 id 都小于等于 0 时,`code` 为 `40001`。 EnvelopeCode: type: string enum: ["0", "40001", "40002", "40003", "40004", "40005", "40101", "40102", "40103", "40301", "40302", "40303", "40401", "40402", "40901", "42901", "42902", "42903", "49901", "50001", "50002", "50003", "50301", "50401"] description: | envelope.code。成功为 "0"。 `40005` media_oversized:媒体分辨率/体积超限(开启 media.user_reject 时拒收)。 Envelope: type: object required: [code, msg, request_id, data] description: | HTTP 与 TCP 应答 Body 的统一信封。业务成败看 `code` 字符串,不看 HTTP 状态(除 `/readyz` 503)。 失败时 `data` 为 `{}`。成功时 `data` 为对象或列表,不会是 `null`。 `request_id` 由服务端生成,客户端不用传;与 Header `X-Request-ID` 相同。 properties: code: allOf: - $ref: "#/components/schemas/EnvelopeCode" description: 字符串。`"0"` 成功,其它失败。不要用 JSON number。 example: "0" msg: type: string description: 展示文案,随 `X-Language` / `Accept-Language` / TCP AUTH `language` 变化。 example: ok request_id: type: string description: 服务端生成的本次请求 id(11 位 base62,`0-9A-Za-z`)。客户端不用传,从响应体读取。 example: 3H8kPq2mN1R data: type: object additionalProperties: true description: 成功为业务对象;失败为 {} default: {} AdminApiResponse: type: object description: popadmin / Java 风格管理端响应(非 V3 envelope) properties: success: { type: boolean } message: { type: string } data: {} MusicTrack: type: object description: | 曲目。管理端可 PATCH 改 title/requireMember/sortOrder/enabled/categoryIds; 封面/背景/音频分别用 POST .../cover|background|audio(multipart `file`)上传或替换。 properties: id: { type: integer, format: int64 } title: { type: string } coverKey: { type: string } audioKey: { type: string } backgroundKey: { type: string, description: 背景图 object_key;可空 } coverUrl: { type: string, description: sparkr 公网 URL;可空直至上传 } audioUrl: { type: string, description: sparkr 公网 URL;可空直至上传 } backgroundUrl: { type: string, description: 背景图 sparkr 公网 URL;可空 } requireMember: { type: boolean, description: true=需会员 } sortOrder: { type: integer, description: 越小越靠前 } enabled: { type: boolean, description: true=上架 } categoryIds: type: array items: { type: integer, format: int64 } description: 归属的分类 id 列表(总分类与/或子分类,多对多) createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } MusicCategory: type: object properties: id: { type: integer, format: int64 } parentId: { type: integer, format: int64, nullable: true, description: null=总分类 } name: { type: string } sortOrder: { type: integer } enabled: { type: boolean } children: type: array items: $ref: "#/components/schemas/MusicCategory" createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } MusicCategoryUpsertRequest: type: object properties: name: { type: string } parentId: { type: integer, format: int64, nullable: true } sortOrder: { type: integer } enabled: { type: boolean } MusicSettings: type: object properties: memberPreviewSeconds: type: integer minimum: 0 maximum: 3600 description: 会员曲免费试听秒数(全局) MusicPlaylist: type: object properties: items: type: array items: $ref: "#/components/schemas/MusicTrack" categories: type: array items: $ref: "#/components/schemas/MusicCategory" description: 启用中的分类树 memberPreviewSeconds: { type: integer } MusicPlaylistGroup: type: object description: 一个分区(叶子分类)及其曲目 properties: categoryId: { type: integer, format: int64 } name: { type: string } parentId: { type: integer, format: int64, nullable: true } items: type: array items: $ref: "#/components/schemas/MusicTrack" MusicGroupedPlaylist: type: object properties: groups: type: array items: $ref: "#/components/schemas/MusicPlaylistGroup" memberPreviewSeconds: { type: integer } SensitiveCategory: type: integer enum: [1, 2, 3, 4, 5, 6, 7, 8, 9] description: | 1=涉黑 2=涉毒 3=涉黄 4=涉赌 5=涉政 6=宗教 7=辱骂 8=广告 9=其他 SensitiveAction: type: integer enum: [1, 2, 3, 4, 5, 6] description: | 1=log 2=replace 3=reject 4=hide(v1 按 reject)5=mute 6=disable SensitiveWord: type: object properties: id: { type: integer, format: int64 } word_raw: { type: string, description: 运营录入原文 } word: { type: string, description: 规范化词面,进自动机 } category: { $ref: "#/components/schemas/SensitiveCategory" } action: { $ref: "#/components/schemas/SensitiveAction" } mute_seconds: { type: integer, description: action=mute 时生效 } enabled: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } SensitiveWordCreateRequest: type: object required: [word, category, action] properties: word: type: string maxLength: 64 description: 词面原文(入库后 Fold 为 word 列) category: { $ref: "#/components/schemas/SensitiveCategory" } action: { $ref: "#/components/schemas/SensitiveAction" } mute_seconds: { type: integer, minimum: 0, default: 0 } enabled: { type: boolean, default: true } SensitiveWordPatchRequest: type: object properties: word: { type: string, maxLength: 64 } category: { $ref: "#/components/schemas/SensitiveCategory" } action: { $ref: "#/components/schemas/SensitiveAction" } mute_seconds: { type: integer, minimum: 0 } enabled: { type: boolean } SensitiveHit: type: object properties: id: { type: integer, format: int64 } user_id: { type: integer, format: int64 } conversation_id: { type: integer, format: int64 } word_id: { type: integer, format: int64 } category: { $ref: "#/components/schemas/SensitiveCategory" } action: { $ref: "#/components/schemas/SensitiveAction" } excerpt: { type: string, maxLength: 128 } real_ip: { type: string } created_at: { type: string, format: date-time } MusicUpsertRequest: type: object description: | 管理端新建/更新曲目元数据。上传封面/背景/音频请用独立 multipart 接口。 `categoryIds` 传入时全量替换曲目分类绑定。 properties: title: { type: string } requireMember: { type: boolean } sortOrder: { type: integer } enabled: { type: boolean } categoryIds: type: array items: { type: integer, format: int64 } FsnGenre: type: object properties: id: { type: integer, format: int64 } slug: { type: string } name: { type: string } description: { type: string } parentId: { type: integer, format: int64, nullable: true } parentSlug: { type: string, nullable: true } sortOrder: { type: integer } children: type: array items: $ref: "#/components/schemas/FsnGenre" FsnAdminGenre: allOf: - $ref: "#/components/schemas/FsnGenre" - type: object properties: createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } FsnNovelCard: type: object properties: id: { type: integer, format: int64 } slug: { type: string } title: { type: string } author: { type: string } synopsis: { type: string } coverUrl: { type: string } status: { type: string } language: { type: string } genres: { type: array, items: { type: string } } rating: { type: number } views: { type: integer, format: int64 } chapterCount: { type: integer } featured: { type: boolean } published: { type: boolean } latestChapterAt: { type: string, format: date-time, nullable: true } createdAt: { type: string, format: date-time } FsnAdminNovelCard: allOf: - $ref: "#/components/schemas/FsnNovelCard" - type: object properties: updatedAt: { type: string, format: date-time } FsnNovelPage: type: object properties: items: type: array items: $ref: "#/components/schemas/FsnNovelCard" page: { type: integer } size: { type: integer } total: { type: integer, format: int64 } totalPages: { type: integer } FsnHome: type: object properties: featured: type: array items: $ref: "#/components/schemas/FsnNovelCard" popular: type: array items: $ref: "#/components/schemas/FsnNovelCard" newReleases: type: array items: $ref: "#/components/schemas/FsnNovelCard" recentlyUpdated: type: array items: $ref: "#/components/schemas/FsnNovelCard" genres: type: array items: $ref: "#/components/schemas/FsnGenre" FsnChapterMeta: type: object properties: id: { type: integer, format: int64 } chapterNumber: { type: integer } slug: { type: string } title: { type: string } publishedAt: { type: string, format: date-time, nullable: true } FsnChapterDetail: type: object properties: id: { type: integer, format: int64 } novelId: { type: integer, format: int64 } novelSlug: { type: string } novelTitle: { type: string } chapterNumber: { type: integer } slug: { type: string } title: { type: string } content: { type: string } publishedAt: { type: string, format: date-time, nullable: true } prev: $ref: "#/components/schemas/FsnChapterMeta" next: $ref: "#/components/schemas/FsnChapterMeta" FsnNovelUpsert: type: object properties: title: { type: string } slug: { type: string } author: { type: string } synopsis: { type: string } coverUrl: { type: string } status: { type: string } language: { type: string } genres: { type: array, items: { type: string } } rating: { type: number } featured: { type: boolean } published: { type: boolean, description: 上下架 } FsnChapterUpsert: type: object properties: chapterNumber: { type: integer } title: { type: string } slug: { type: string } content: { type: string } published: { type: boolean } FsnGenreUpsert: type: object properties: name: { type: string } slug: { type: string } description: { type: string } parentId: { type: integer, format: int64, nullable: true } parentSlug: { type: string, nullable: true } sortOrder: { type: integer } EnumsEnvelope: type: object required: [code, msg, request_id, data] properties: code: $ref: "#/components/schemas/EnvelopeCode" msg: type: string request_id: type: string data: $ref: "#/components/schemas/EnumCatalog" EnumItem: type: object required: [value, name, label] properties: value: description: 与 JSON/TCP/SQL 一致。数字集合为 integer,字符串集合为 string。 oneOf: - type: integer - type: string name: type: string description: 稳定英文标识,不随语言变化 label: type: string description: 当前请求语言下的展示文案 EnumCatalog: type: object properties: gender: type: array items: $ref: "#/components/schemas/EnumItem" user_status: type: array items: $ref: "#/components/schemas/EnumItem" conversation_type: type: array items: $ref: "#/components/schemas/EnumItem" message_kind: type: array items: $ref: "#/components/schemas/EnumItem" message_type: type: array items: $ref: "#/components/schemas/EnumItem" message_status: type: array items: $ref: "#/components/schemas/EnumItem" relation_kind: type: array items: $ref: "#/components/schemas/EnumItem" friend_request_status: type: array items: $ref: "#/components/schemas/EnumItem" friend_source: type: array items: $ref: "#/components/schemas/EnumItem" report_reason: type: array items: $ref: "#/components/schemas/EnumItem" group_status: type: array items: $ref: "#/components/schemas/EnumItem" group_role: type: array items: $ref: "#/components/schemas/EnumItem" mailbox_event_type: type: array items: $ref: "#/components/schemas/EnumItem" tcp_command: type: array items: $ref: "#/components/schemas/EnumItem" envelope_code: type: array items: $ref: "#/components/schemas/EnumItem" device_type: type: array items: $ref: "#/components/schemas/EnumItem" language: type: array items: $ref: "#/components/schemas/EnumItem" upload_purpose: type: array items: $ref: "#/components/schemas/EnumItem" system_kind: type: array items: $ref: "#/components/schemas/EnumItem" SendVerificationCodeRequest: type: object required: [purpose, target] properties: purpose: $ref: "#/components/schemas/VerificationPurpose" target: type: string description: 邮箱或大陆 11 位手机号(可带 +86) DeleteAccountRequest: type: object properties: verify_code: type: string description: 注销验证码(purpose=account_delete)。测试账号可省略。 VerificationPurpose: type: integer enum: [1, 2] description: 1=登录 2=注销账号 LoginRequest: type: object required: [email, verify_code, app_id, device_type] description: | Breaking:删除客户端 `device_id`;新增 `app_id`、整数 `device_type`、物理指纹字段。 指纹:`payload="{device_type}:{idfv|android_id}"` → `device_id=uint64(SHA256(HEX(SHA256(payload)))[0:8])`(非 0)。 properties: email: type: string format: email verify_code: type: string description: 邮箱验证码。测试账号 test01@spark.com~test100@spark.com 可任意填写。 app_id: type: integer minimum: 1 description: 当前客户端 App,见 meta `app_id` 枚举(JWT claim `aid`)。 device_type: $ref: "#/components/schemas/DeviceType" platform: type: integer format: int32 enum: [1, 2, 3, 4, 5, 6] description: 与 device_type 相同枚举;仅当未传 device_type 时可作别名 device_name: type: string maxLength: 128 idfv: type: string maxLength: 36 description: iOS / macOS 必填(物理设备标识,服务端据此生成 device_id)。 android_id: type: string maxLength: 20 description: Android / Harmony 必填。 LoginData: type: object required: [access_token, refresh_token, expires_in, refresh_expires_in, user_id, device_id, app_id, spark_id] properties: access_token: type: string description: JWT。claims 含 sub、did、aid、use=access refresh_token: type: string description: JWT。claims 含 sub、did、aid、use=refresh;刷新后 aid 不变 expires_in: type: integer format: int64 description: access TTL in seconds (default 604800 = 7 days) example: 604800 refresh_expires_in: type: integer format: int64 description: refresh TTL in seconds (default 2592000 = 30 days) example: 2592000 user_id: type: integer format: int64 description: Snowflake user id(= JWT sub) device_id: type: integer format: int64 description: 服务端按物理指纹生成的设备 id(JWT `did`)。客户端持久化该值,勿自行生成。 app_id: type: integer description: 与 JWT claim `aid` 一致 spark_id: type: string description: Public handle, unique and user-changeable. New and updated values are 8-16 characters. RefreshRequest: type: object required: [refresh_token] properties: refresh_token: type: string PatchMeRequest: type: object properties: spark_id: type: string minLength: 8 maxLength: 16 pattern: "^[a-zA-Z][a-zA-Z0-9_]{7,15}$" nickname: type: string minLength: 1 maxLength: 64 avatar: type: string maxLength: 512 description: | 对象存储 key。仅 `perm/avatar/`、`perm/group/` 可匿名 GET(桶本身不公开)。 URL:`{public_endpoint}/spark/{avatar}`,Nginx 也可省略桶名 `{public_endpoint}/{avatar}`。 `chat/`、`perm/fav/`、`perm/moment/` 必须 Presigned GET。 gender: $ref: "#/components/schemas/Gender" birthday: type: string description: YYYY-MM-DD;空字符串表示清除 example: "1994-05-01" FriendApplyRequest: type: object properties: uid: type: integer format: int64 email: type: string format: email spark_id: type: string qrcode: type: string description: 个人二维码扫描票据(shortid),不含 uid reason: type: string maxLength: 128 remark: type: string maxLength: 64 FriendApplyData: type: object required: [status, uid] properties: status: $ref: "#/components/schemas/FriendRequestStatus" request_id: type: integer format: int64 description: status=1 时返回,待处理申请 id uid: type: integer format: int64 description: 对方 Snowflake uid FriendAcceptRequest: type: object required: [request_id] properties: request_id: type: integer format: int64 remark: type: string maxLength: 64 FriendRemarkRequest: type: object properties: remark: type: string maxLength: 64 FriendReportRequest: type: object required: [reason] properties: reason: $ref: "#/components/schemas/ReportReason" detail: type: string maxLength: 512 CreateUploadRequest: type: object required: [purpose, mime, size] properties: purpose: $ref: "#/components/schemas/UploadPurpose" mime: type: string example: image/jpeg description: | avatar / group_avatar / chat_image 仅 image/jpeg、image/jpg、image/png。 iOS HEIC、Android WebP 须客户端转成 JPEG/PNG 再申请凭证。 size: type: integer format: int64 minimum: 1 group_id: type: integer format: int64 minimum: 1 description: purpose=group_avatar 时必填,为自增群号 CreateUploadBatchRequest: type: object required: [items] properties: items: type: array minItems: 1 maxItems: 3 items: $ref: "#/components/schemas/CreateUploadRequest" CreateDownloadRequest: type: object required: [object_key] properties: object_key: type: string maxLength: 512 CreateDownloadBatchRequest: type: object required: [object_keys] properties: object_keys: type: array minItems: 1 maxItems: 9 items: type: string maxLength: 512 SendMessageRequest: type: object required: [client_msg_id, content] properties: conversation_id: type: integer format: int64 peer_id: type: integer format: int64 description: 单聊对方 uid;与 conversation_id 至少填一个 client_msg_id: type: string maxLength: 64 msg_id: type: integer format: int64 description: 可选,须为 ±5 分钟内的 Snowflake content: type: object required: [t] properties: t: $ref: "#/components/schemas/MessageType" reply_to: type: integer format: int64 description: 被回复消息 MsgId text: type: object properties: body: type: string image: type: object description: | t=2。点开看 `key`(压缩预览);气泡用 `thumb_key`;`orig_key` 有则显示「查看原图」。 三档由客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传。服务端不转码;不要传 HEIC/WebP。 required: [key] properties: key: type: string description: 点开查看用的压缩图 object_key thumb_key: type: string description: 气泡缩略图,可省略 orig_key: type: string description: 原图 object_key;空则没有「查看原图」 orig_size: type: integer format: int64 description: 原图像素文件大小,供「查看原图 3.2MB」按钮 orig_w: type: integer orig_h: type: integer mime: type: string w: type: integer h: type: integer size: type: integer format: int64 at_all: type: boolean description: "@所有人,仅群主/管理员" at_uids: type: array maxItems: 50 items: type: integer format: int64 description: "@的群成员 uid" system: type: object properties: kind: $ref: "#/components/schemas/SystemKind" body: type: string CreateFavoriteRequest: type: object required: [msg_id] properties: msg_id: type: integer format: int64 minimum: 1 CreateGroupRequest: type: object required: [name, member_uids] properties: name: type: string minLength: 1 maxLength: 32 member_uids: type: array minItems: 1 maxItems: 199 items: type: integer format: int64 UpdateGroupRequest: type: object properties: name: type: string minLength: 1 maxLength: 32 avatar: type: string muted_all: type: boolean announcement: type: string maxLength: 1024 InviteGroupMembersRequest: type: object required: [uids] properties: uids: type: array minItems: 1 items: type: integer format: int64 UpdateGroupMemberRequest: type: object properties: role: allOf: - $ref: "#/components/schemas/GroupRole" description: 仅 2=管理员 3=普通成员;群主只能通过转让变更 speak_muted: type: boolean nickname: type: string maxLength: 32 remark: type: string maxLength: 32 description: 对该群的备注,仅自己可改 TransferGroupRequest: type: object required: [uid] properties: uid: type: integer format: int64 LoginEnvelope: type: object required: [code, msg, request_id, data] properties: code: type: string msg: type: string request_id: type: string data: $ref: "#/components/schemas/LoginData" AuthData: type: object properties: user_id: type: integer format: int64 device_id: type: integer format: int64 MsgIDData: type: object properties: msg_id: type: integer format: int64 headers: X-Request-ID: schema: type: string description: 服务端生成的本次请求 id。与响应体 `request_id` 相同。客户端不用传。 X-Device-ID: schema: type: string description: Optional. Must match JWT did when present(服务端生成的 device_id)。 X-App-ID: schema: type: integer description: Optional. Must match JWT aid(app_id) when present. X-App-Version: schema: type: string Accept-Language: schema: $ref: "#/components/schemas/Language" example: zh-CN description: 用户语言。也可用 X-Language。缺省 zh-CN。 X-Language: schema: $ref: "#/components/schemas/Language" description: 覆盖 Accept-Language 的简洁语言标签。 X-Real-IP: schema: type: string description: Nginx PROXY / SNI 写入的客户端真实 IP,进入 reqctx.RealIP。 parameters: Authorization: in: header name: Authorization required: true schema: type: string example: Bearer eyJhbGciOiJIUzI1NiJ9... description: Header。`Bearer `。登录/发码/刷新/探活/文档/枚举不要带。 X-Device-ID: in: header name: X-Device-ID required: false schema: type: string example: "7" description: Header。十进制 device_id,须等于登录响应 / JWT did(服务端按物理指纹生成)。不放 Body。 X-App-ID: in: header name: X-App-ID required: false schema: type: integer example: 1 description: Header。若传须等于 JWT aid(app_id)。不放业务 Body。 X-App-Version: in: header name: X-App-Version required: false schema: type: string example: 1.0.0 description: Header。客户端版本。登录时也可在 Body 带 app_version;此后用本 Header。 Accept-Language: in: header name: Accept-Language required: false schema: $ref: "#/components/schemas/Language" description: Header。用户语言。缺省 zh-CN。无 X-Language 时生效。 X-Language: in: header name: X-Language required: false schema: $ref: "#/components/schemas/Language" description: Header。覆盖 Accept-Language。决定 envelope.msg 语言。不放 Body。 FriendUID: in: path name: uid required: true schema: type: integer format: int64 description: 对方 Snowflake uid。 FavoriteID: in: path name: id required: true schema: type: integer format: int64 description: 收藏 Snowflake id。 MomentID: in: path name: id required: true schema: type: integer format: int64 description: 朋友圈 Snowflake post_id。 SquareChannel: in: path name: square_channel required: true schema: $ref: "#/components/schemas/SquareChannel" description: 广场实例 square / squarenovel / squaresteps / squarehealth SquarePostID: in: path name: id required: true schema: type: integer format: int64 description: 广场 Snowflake post_id。 GroupID: in: path name: id required: true schema: type: integer format: int64 description: 自增群号(不是 conversation_id)。 MessageID: in: path name: id required: true schema: type: integer format: int64 description: Snowflake MsgId。