# spark-server Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程)。公网只开 **443**,HTTP / IM / 对象存储用三个域名、Nginx SNI 分流。 仓库: 完整设计:[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` | 代码规范 |