spark-server/README.md
DESKTOP-L8OUL9F\gfl0034 c0ed0d83de fix: 命令 8 接受 last_msg_id 作为已读水位
客户端已读请求常只有 conversation_id 和 last_msg_id。任一消息 id 大于 0 即写入 last_read_msg_id 并清 Redis 未读,不再因此返回 40001。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:26:48 +08:00

353 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | → `conversation_id`,以及 `msg_id` 或 `last_msg_id`(任一大于 0;两者都有时用 `msg_id`)。成功 `data` 为 `conversation_id` 与 `msg_id`,`msg_id` 为落库后的 `last_read_msg_id`(只增不减)。清该成员 Redis 未读,并把会话从 unread ZSET 挪到 read ZSET。水位前进时给该用户其他在线设备推 Command 8(不系统推送、不写 mailbox)。缺 `conversation_id`,或两个消息 id 都小于等于 0,为 `40001` |
| 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
make setup # 安装 pre-commit + 检查 go / golangci-lint(缺工具会失败,不能跳过 make test)
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` | 代码规范 |