合并 origin/main 设备注册后,纳入本地 music 模块、seed 命令与 popadmin 相关路由/文档;music migration 编号为 0020 以避免与 0019_devices 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
353 lines
16 KiB
Markdown
353 lines
16 KiB
Markdown
# spark-server
|
||
|
||
Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程)。公网只开 **443**,HTTP / IM / 对象存储用三个域名、Nginx SNI 分流。
|
||
|
||
仓库:<https://gitea.wyrs365.com/Spark/spark-server.git>
|
||
完整设计:[docs/architecture-v3.md](docs/architecture-v3.md)
|
||
验证码发码 / 封控:[docs/sms.md](docs/sms.md)
|
||
朋友圈(HTTP v1,无 TCP Push):[docs/moments.md](docs/moments.md)
|
||
在线协议:`GET /docs`(Swagger)、`/docs/enums`、`/docs/errors`,规范 `GET /openapi.yaml`。
|
||
|
||
不上 Kafka、K8s、微服务。不要给 gnet 绑公网端口,不要在 EventLoop 里打 MySQL。
|
||
|
||
---
|
||
|
||
## 它做什么
|
||
|
||
即时通讯:登录、好友、群、发消息、增量同步、撤回删除、媒体直传、朋友圈。
|
||
|
||
| 层 | 职责 |
|
||
| --- | --- |
|
||
| 实时 | TCP 长连接(gnet)。在线推送 `MESSAGE`,心跳 30s,空闲 120s 断开 |
|
||
| 正文 | MySQL。Snowflake `MsgId` 是主键、排序、同步游标。没有 `ConversationSeq` |
|
||
| 会话列表 / 未读 | Redis ZSET。群热路径不写 MySQL inbox 行 |
|
||
| 扇出 | Redis `deliver:queue` + worker。`SEND_ACK` = MySQL COMMIT **且** 入队成功 |
|
||
| 媒体 | 客户端拿 Presigned URL 直传 RustFS。消息里只存 `object_key` |
|
||
|
||
HTTP 和 TCP 调同一套 `internal/module/*/logic`。Gin handler 只做绑定和写信封。
|
||
|
||
---
|
||
|
||
## 生产入口
|
||
|
||
| 用途 | 域名 | 说明 |
|
||
| --- | --- | --- |
|
||
| 官网 | `https://sparkmessenger.wyrs365.com` | App 落地页(别名 `sparkmsg.wyrs365.com`) |
|
||
| HTTP API | `https://sparkapi.wyrs365.com` | REST `/v1/...`,文档 `/docs` |
|
||
| IM 长连接 | `sparkim.wyrs365.com:443` | TLS + 自定义 Frame,不是 WebSocket |
|
||
| 对象存储 | `https://sparkr.wyrs365.com` | RustFS S3 path-style,Presigned PUT/GET |
|
||
|
||
本机开发:HTTP `http://127.0.0.1:8080`,TCP 明文 `127.0.0.1:8090`。
|
||
|
||
ECS 上进程只绑回环,由 Nginx 终结 TLS:
|
||
|
||
```text
|
||
客户端 :443 (ssl_preread)
|
||
├─ sparkapi.wyrs365.com → 127.0.0.1:8443 → Gin 127.0.0.1:18080
|
||
├─ sparkr.wyrs365.com → 127.0.0.1:8443 → RustFS 127.0.0.1:9000
|
||
└─ sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
|
||
→ PROXY → gnet 127.0.0.1:9090
|
||
```
|
||
|
||
主机 `:9000` 是 RustFS S3,所以 IM gnet 用 **9090**。安全组只放行 443。
|
||
|
||
---
|
||
|
||
## 硬约束(不要改)
|
||
|
||
| ID | 规则 |
|
||
| --- | --- |
|
||
| C1 | 消息正文在 MySQL。Snowflake `MsgId` 是 PK / 排序 / 同步游标。没有 ConversationSeq |
|
||
| C2 | gnet EventLoop 不调 MySQL / Redis / 对象存储 / 扇出。入站进 TaskPool;扇出走 `deliver:queue` |
|
||
| C3 | 保密靠 TLS。Frame 只有薄头 + 可选 gzip。没有应用层 ECDH/AES |
|
||
| C4 | 公网只有 443。gnet / Gin 绑回环 |
|
||
| C5 | `SEND_ACK` 必须在 COMMIT **且** deliver `Push` 成功之后。入队失败不 ACK |
|
||
| C6 | 限流 / 审计用 PROXY / `X-Real-IP` 的 `RealIP`,不用 gnet `RemoteAddr`(那是 127.0.0.1) |
|
||
| C7 | 会话列表 / 未读 / 预览在 Redis ZSET。群热路径不 upsert MySQL inbox |
|
||
|
||
热路径禁止 `context.TODO()`。推送超时不踢连接。不要把媒体字节写进 `messages.content`。
|
||
|
||
---
|
||
|
||
## 统一响应
|
||
|
||
HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符串,不要用数字 `0` 比较。除 `/readyz` 未就绪是 HTTP 503 外,业务失败也是 HTTP 200。
|
||
|
||
```json
|
||
{"code":"0","msg":"ok","request_id":"3H8kPq2mN1R","data":{}}
|
||
```
|
||
|
||
| 字段 | 规则 |
|
||
| --- | --- |
|
||
| `code` | `"0"` 成功;其它失败。全表见 [/docs/errors](https://sparkapi.wyrs365.com/docs/errors) |
|
||
| `msg` | 展示文案,随语言变。成功一般为 `ok` |
|
||
| `request_id` | 服务端生成,**11 位 base62**(`0-9A-Za-z`)。客户端不用传。HTTP 同时写 Header `X-Request-ID` |
|
||
| `data` | 成功为业务对象;失败固定 `{}`。缺字段当空对象,不要当 `null` |
|
||
|
||
`request_id` 和 TCP Frame 头里的 `RequestID`(uint64)不是一回事:后者是客户端填的配对号,服务端原样带回。
|
||
|
||
### HTTP 请求放哪
|
||
|
||
| 位置 | 内容 |
|
||
| --- | --- |
|
||
| Header | `Authorization: Bearer …`、`X-Device-ID`、`X-Language` 或 `Accept-Language`、`X-App-Version`、`Content-Type` |
|
||
| Body | POST/PATCH 业务字段。不要把 token / 语言 / device_id 塞进业务 JSON(登录 Body 带 `device_id`,刷新 Body 带 `refresh_token`) |
|
||
| Path | 资源 id:好友 uid、群号、MsgId |
|
||
| Query | GET 筛选 / 分页 |
|
||
|
||
语言:`en`(缺省)/ `zh-CN` / `zh-TW`(`X-Language` 或 `Accept-Language`)。发码接口 **`X-Language` 必填**。网关写 `X-Real-IP`,客户端不要发。
|
||
|
||
---
|
||
|
||
## 登录
|
||
|
||
1. (可选)`POST /v1/sms/codes`,Header **`X-Language` 必填**,Body `{"purpose":1,"target":"…"}`。封控:**RealIP 令牌桶**(默认 5/s,与 login/refresh 共用)+ **同 target 60s 冷却**,均 `42902` — 见 [docs/sms.md](docs/sms.md)。
|
||
2. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。`device_id` 由**客户端生成并持久化**,服务端不分配;写入 JWT `did`。
|
||
3. 其它 HTTP:`Authorization: Bearer …`,可选 `X-Device-ID`(须等于登录时的 device_id)。
|
||
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
|
||
5. 在线发消息、收推送、增量同步走 **TCP**。`POST /v1/messages` 只是 HTTP 调试通道,不要当 App 主路径。
|
||
|
||
测试号 `test01@spark.com`~`test100@spark.com` 可跳过发码,登录任意 `verify_code`。
|
||
|
||
`uid` 就是 Snowflake `users.id`。对外展示用可变的 `spark_id`,不要把 uid 写进对象存储路径。
|
||
|
||
### 演示种子(欢迎消息 / 机器人 / 示例群)
|
||
|
||
幂等命令(可重复执行):
|
||
|
||
```bash
|
||
make seed
|
||
# 或 go run ./cmd/seed -config configs/config.yaml
|
||
```
|
||
|
||
会确保:
|
||
|
||
| 项 | 说明 |
|
||
| --- | --- |
|
||
| 100 个机器人 | `bot001@spark.bot`~`bot100@spark.bot`,英文昵称 + 字母头像(`LetterAvatarDataURI`) |
|
||
| 测试号 | 若尚无则创建 `test01`~`test10` |
|
||
| 正式/测试用户 | 每人对 `Spark Assistant`(bot001)有好友边;一条 `system.welcome` 英文系统消息;一条英文打招呼文本 |
|
||
| 示例群 | 群名 `Spark Samples`,含多个英文昵称机器人 + 上述用户;覆盖文本/图片/语音/视频/表情/GIF/文件/位置/系统等类型 |
|
||
|
||
「正式用户」= 库中已有、邮箱不是 `bot*@spark.bot` 的活跃账号;「测试用户」= `test01@spark.com`~`test10@spark.com`。
|
||
|
||
---
|
||
|
||
## HTTP 资源
|
||
|
||
前缀 `/v1/{resource}`。
|
||
|
||
| 组 | 路径 | 说明 |
|
||
| --- | --- | --- |
|
||
| 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 |
|
||
| 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 |
|
||
| 枚举 | `GET /v1/meta/enums` | 无需登录 |
|
||
| 验证码 | `/v1/sms/codes` | 发码(封控见 [docs/sms.md](docs/sms.md)) |
|
||
| 认证 | `/v1/auth` | 登录、刷新、登出当前设备 |
|
||
| 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) |
|
||
| 好友 | `/v1/friends` | 申请、同意、查找(`GET /lookup?q=`)、备注、置顶、拉黑、举报 |
|
||
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
|
||
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |
|
||
| 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` |
|
||
| 朋友圈 | `/v1/moments` | Feed、相册、赞评、通知、封面/不看他 |
|
||
| 会话 | `/v1/conversations` | 列表、免打扰 |
|
||
| 消息 | `/v1/messages` | 历史 / 撤回 / 删除(主路径仍是 TCP) |
|
||
|
||
---
|
||
|
||
## TCP 协议
|
||
|
||
不是 WebSocket、不是 HTTP、不是 Protobuf。对 `sparkim.wyrs365.com:443` 做 TLS(SNI 必须是该域名),之后写二进制 Frame。
|
||
|
||
### Frame(大端,固定 23 字节头)
|
||
|
||
```text
|
||
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_ACK 必须带回推送帧的 SequenceID
|
||
offset 19 BodyLength uint32 Body 字节数(压缩后)
|
||
offset 23 Body bytes UTF-8 JSON
|
||
```
|
||
|
||
先读 23 字节,再按 `BodyLength` 读 Body。Magic 不对或总长超过 64KB 应断开。Body ≥ 512B 且 gzip 更小时可压缩。
|
||
|
||
**请求 Body 是业务 JSON,不要包信封。** 应答和 `MESSAGE` 推送才是 `{code,msg,request_id,data}`。失败时 Command 仍为请求 Command(发送失败不是 `SEND_ACK`)。
|
||
|
||
| 值 | 名 | 方向 |
|
||
| --- | --- | --- |
|
||
| 1 | AUTH | → 请求 `{"token","device_id","app_version","language"}` |
|
||
| 2 | HEARTBEAT | → 空 Body |
|
||
| 3 | GENERATE_MSG_ID | → 空;应答 `data.msg_id` |
|
||
| 4 | SEND_MESSAGE | → 与 HTTP 发消息同一 JSON |
|
||
| 5 | SEND_ACK | ← 仅 4 的成功应答 |
|
||
| 6 | MESSAGE | ← 推送 |
|
||
| 7 | PUSH_ACK | → 空;`SequenceID` = 推送帧的 SequenceID |
|
||
| 8 | READ_MESSAGE | → |
|
||
| 9 | RECALL_MESSAGE | → `{"msg_id"}` |
|
||
| 10 | SYNC_INBOX | → 会话列表增量 |
|
||
| 12 | SYNC_MESSAGES | → 某会话消息增量 |
|
||
| 14 | KICK | ← 同设备顶号 |
|
||
| 15 | DELETE_MESSAGE | → `{"msg_id"}` |
|
||
|
||
除 AUTH / HEARTBEAT / PUSH_ACK 外须先 AUTH,否则 `40102`。
|
||
|
||
上线同步顺序:**先 `SYNC_INBOX`,再按会话 `SYNC_MESSAGES`**。游标是 Snowflake `MsgId` / inbox score,不是 ConversationSeq。
|
||
|
||
Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
|
||
|
||
---
|
||
|
||
## 发消息路径
|
||
|
||
必须按这个顺序(C5):
|
||
|
||
1. TaskPool(禁止在 EventLoop 里做)
|
||
2. 权限(好友 / 拉黑 / 群禁言)——失败则不 INSERT
|
||
3. Snowflake MsgId(或客户端 MsgId,须在 ±5 分钟内)
|
||
4. MySQL 事务:消息表 + `conversations.last_msg_id`
|
||
5. `DeliverQueue.Push`
|
||
6. `SEND_ACK`
|
||
|
||
第 5 步失败不 ACK。客户端用同一个 `client_msg_id` 重试。发送设备不回推 `MESSAGE`,其它设备必须推。同 `device_id` 顶旧连接,下发 `KICK`。
|
||
|
||
私聊与群消息分表存储。消息类型 1–9:文本 / 图片 / 语音 / 视频 / 表情 / gif / 文件 / 位置 / 系统。`content` 是 JSON 序列化后的 **bytes**,不是字符串。
|
||
|
||
---
|
||
|
||
## 媒体 `object_key`
|
||
|
||
客户端 **不自己拼 key**。`POST /v1/files` 只传 `purpose` / `mime` / `size`(群头像再加 `group_id`),服务端签发 Presigned PUT。PUT 必须原样带返回的 `headers`(`Content-Type`、`Content-Length`、`If-None-Match: *`)。同一 key 第二次 PUT 为 412。
|
||
|
||
文件名是 **16 位 base62 随机串**(12 字节),不占用 Snowflake。路径不含 uid。
|
||
|
||
| purpose | object_key | 生命周期 |
|
||
| --- | --- | --- |
|
||
| `avatar` | `perm/avatar/{yyyyMMdd}/{name}.{ext}` | 永久;匿名 GET |
|
||
| `group_avatar` | `perm/group/{群号}/{name}.{ext}` | 永久;匿名 GET |
|
||
| `chat_image` | `chat/image/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||
| `chat_voice` | `chat/voice/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||
| `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||
| `chat_gif` | `chat/gif/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||
| `chat_file` | `chat/file/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||
| `moment_image` | `perm/moment/image/{yyyyMMdd}/{name}.{ext}` | 永久;删帖删除 |
|
||
| `moment_video` | `perm/moment/video/{yyyyMMdd}/{name}.{ext}` | 永久;删帖删除 |
|
||
| `moment_cover` | `perm/moment/cover/{yyyyMMdd}/{name}.{ext}` | 永久;换封面删除旧对象 |
|
||
|
||
日期 UTC `yyyyMMdd`。`ext` 由 mime 映射,未知为 `bin`。收藏复制到 `perm/fav/{yyyyMMdd}/{name}.{ext}`。朋友圈媒体不引用 `chat/`。
|
||
|
||
合法 key:以 `perm/` 或 `chat/` 开头;禁止 `..`、空白、`\`、前导 `/`;最长 512。
|
||
|
||
头像匿名读:`perm/avatar/`、`perm/group/` 可直接 GET `{public_endpoint}/spark/{key}`(Nginx 也可省略 `/spark`)。聊天、收藏、朋友圈仍须 `POST /v1/files/downloads`。
|
||
|
||
发图片:客户端只 PUT 原图,再 `POST /v1/files/variants` 让服务端出缩略图和预览。消息里只存 key。
|
||
|
||
进程访问 RustFS 用内网 `object_storage.endpoint`(Docker 里是 `rustfs:9000`),签给客户端的 URL 用 `public_endpoint`(`https://sparkr.wyrs365.com`)。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
```text
|
||
cmd/server 进程入口
|
||
cmd/migrate 按序执行 migrations/*.sql
|
||
configs/config.yaml 本机开发配置
|
||
deploy/docker/ 生产镜像、compose、deploy.sh
|
||
deploy/nginx/ 443 SNI(host/ 是这台 ECS 的 drop-in)
|
||
docs/architecture-v3.md 架构全文
|
||
docs/clients/ TCP Java / Swift demo
|
||
internal/bootstrap 读配置、接线、生命周期
|
||
internal/transport/http Gin;handler 只绑定
|
||
internal/transport/tcp gnet Frame + TaskPool
|
||
internal/module/{biz}/entity 领域类型,无 I/O
|
||
internal/module/{biz}/model 一张表一个 Go 文件(GORM + COMMENT)
|
||
internal/module/{biz}/logic 一个功能一个文件,禁止 import model
|
||
internal/pkg errs / i18n / reply / reqctx / validate
|
||
internal/infrastructure MySQL / Redis / Snowflake / zap / RustFS
|
||
migrations/ SQL,改完必须在本机跑 migrate
|
||
```
|
||
|
||
`logic` 不 import `model`。接线在 `internal/bootstrap`。表和列必须有 COMMENT。
|
||
|
||
---
|
||
|
||
## 本机开发
|
||
|
||
需要:Go 1.26、MySQL 8、Redis。对象存储可空(上传接口会不可用)。
|
||
|
||
```bash
|
||
# 配置 configs/config.yaml(env: dev)
|
||
make migrate # 应用 migrations/*.sql
|
||
make start # go run ./cmd/server -config configs/config.yaml
|
||
# 或
|
||
make run # 先编 bin/spark-server 再启动
|
||
```
|
||
|
||
覆盖配置:`make start CONFIG=path/to.yaml`。
|
||
|
||
```bash
|
||
make test
|
||
make lint # funlen 100,lll 240
|
||
```
|
||
|
||
探活:
|
||
|
||
```bash
|
||
curl -sS http://127.0.0.1:8080/healthz
|
||
curl -sS http://127.0.0.1:8080/readyz
|
||
```
|
||
|
||
`env: prod`(或 `SPARK_ENV=prod`)会拒绝空 MySQL DSN、空 Redis、以及默认 JWT secret。
|
||
|
||
改了 `migrations/*.sql` 后自己跑 `make migrate`,不要留给部署。
|
||
|
||
---
|
||
|
||
## 部署(ECS)
|
||
|
||
`make deploy` → 本机交叉编译 linux/amd64、打运行时镜像、再 `docker save` 到 ECS `docker load`。默认 `root@47.236.66.9`,密钥 `~/.ssh/spark-ecs`。需要本机有 `go` 和 `docker`。只打镜像:`make image`。
|
||
|
||
主机目录:
|
||
|
||
| 路径 | 内容 |
|
||
| --- | --- |
|
||
| `/data/spark-server/compose/docker-compose.yaml` | 只跑已 load 的 `spark-server:latest` |
|
||
| `/data/spark-server/configs/config.yaml` | 运行配置(来自 `deploy/docker/config.docker.yaml`) |
|
||
| `/data/spark-server/logs` | 进程日志 |
|
||
| `/data/spark-server/images/spark-server.tar.gz` | 本机 `docker save` 上传 |
|
||
|
||
容器加入已有 `shell_default` 网络(mysql / redis / rustfs)。发布:
|
||
|
||
- `127.0.0.1:18080` → 容器 HTTP 8080
|
||
- `127.0.0.1:9090` → 容器 gnet 9090
|
||
|
||
Nginx drop-in:`deploy/nginx/host/`,安装脚本 `bash deploy/nginx/install-host.sh`。不要覆盖整份 `/etc/nginx/nginx.conf`。
|
||
|
||
Snowflake `snowflake_worker_id` 每进程唯一。时钟回拨会拒绝发号。
|
||
|
||
---
|
||
|
||
## 日志与 ID
|
||
|
||
- zap 控制台和/或滚动文件(`log.console` / `log.file`)。GORM SQL 走同一套 zap。
|
||
- HTTP `request_id`:11 位 base62,响应体 + `X-Request-ID`。
|
||
- 对象文件名:16 位 base62,与 Snowflake 号段分开。
|
||
- Snowflake 只给 uid、MsgId、会话 id、收藏行 id 等业务主键。
|
||
|
||
---
|
||
|
||
## 相关文档
|
||
|
||
| 文档 | 内容 |
|
||
| --- | --- |
|
||
| [docs/architecture-v3.md](docs/architecture-v3.md) | 架构全文 |
|
||
| `GET /docs` | OpenAPI / Swagger |
|
||
| `GET /docs/enums` | 可跳转枚举 |
|
||
| `GET /docs/errors` | 信封错误码 |
|
||
| [deploy/nginx/README.md](deploy/nginx/README.md) | 443 SNI |
|
||
| `docs/clients/` | TCP 客户端示例 |
|
||
| `.cursor/skills/spark-im-v3/` | 实现约定(给 Agent) |
|
||
| `.golangci.yaml` | 代码规范 |
|