Go to file
2026-09-22 17:07:26 +08:00
.cursor feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
api/proto feat: init 2026-09-16 19:08:06 +08:00
cmd feat: update Primary Key 2026-09-20 11:38:27 +08:00
configs feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
deploy feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
docs feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
examples/android feat: implement Spark IM V3 modular monolith 2026-09-17 19:52:11 +08:00
internal feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
migrations feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
website Hide developer docs from Spark Messenger marketing site. 2026-09-22 15:20:21 +08:00
.dockerignore feat: 1、optimmize deploy 2、message insert 2026-09-22 11:01:56 +08:00
.gitignore feat: user delete account 2026-09-20 17:49:09 +08:00
.golangci.yaml feat: 私聊 群聊 群管理 上传 2026-09-18 14:50:41 +08:00
AGENTS.md feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
Dockerfile feat: 1、optimmize deploy 2、message insert 2026-09-22 11:01:56 +08:00
go.mod feat: implement Spark IM V3 modular monolith 2026-09-17 19:52:11 +08:00
go.sum feat: implement Spark IM V3 modular monolith 2026-09-17 19:52:11 +08:00
Makefile feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00
README.md feat: 1、square 2、smtp 2026-09-22 17:07:26 +08:00

spark-server

Spark IM 服务端。Go 1.26 模块化单体(一台机器、一个进程)。公网只开 443,HTTP / IM / 对象存储用三个域名、Nginx SNI 分流。

仓库:https://gitea.wyrs365.com/Spark/spark-server.git
完整设计:docs/architecture-v3.md
朋友圈(HTTP v1,无 TCP Push):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:

客户端 :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。

{"code":"0","msg":"ok","request_id":"3H8kPq2mN1R","data":{}}
字段 规则
code "0" 成功;其它失败。全表见 /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 筛选 / 分页

语言:zh-CN(缺省)/ en / zh-TW。网关写 X-Real-IP,客户端不要发。


登录

  1. (可选)POST /v1/sms/codes,Header X-Language 必填,Body {"purpose":1,"target":"…"}。
  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~test10@spark.com 可跳过发码,登录任意 verify_code。

uid 就是 Snowflake users.id。对外展示用可变的 spark_id,不要把 uid 写进对象存储路径。


HTTP 资源

前缀 /v1/{resource}。

组 路径 说明
探活 /healthz /readyz 存活;未就绪 HTTP 503
文档 /docs /docs/enums /docs/errors /openapi.yaml 无需登录
枚举 GET /v1/meta/enums 无需登录
认证 /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 字节头)

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)。


目录

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。对象存储可空(上传接口会不可用)。

# 配置 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。

make test
make lint             # funlen 100,lll 240

探活:

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 架构全文
GET /docs OpenAPI / Swagger
GET /docs/enums 可跳转枚举
GET /docs/errors 信封错误码
deploy/nginx/README.md 443 SNI
docs/clients/ TCP 客户端示例
.cursor/skills/spark-im-v3/ 实现约定(给 Agent)
.golangci.yaml 代码规范