后端 GET /im/messages 增加 start/end(最长12个月),前端格式化 ts 并提供日/周/月/年/总/自定义筛选。 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|---|---|---|
| .cursor | ||
| api/proto | ||
| cmd | ||
| configs | ||
| deploy | ||
| docs | ||
| examples/android | ||
| fullstorynovel-web | ||
| health-web | ||
| internal | ||
| migrations | ||
| scripts/git-hooks | ||
| website | ||
| .dockerignore | ||
| .gitignore | ||
| .golangci.yaml | ||
| AGENTS.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
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/sms.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 筛选 / 分页 |
语言:en(缺省)/ zh-CN / zh-TW(X-Language 或 Accept-Language)。发码接口 X-Language 必填。网关写 X-Real-IP,客户端不要发。
登录
- (可选)
POST /v1/sms/codes,HeaderX-Language必填,Body{"purpose":1,"target":"…"}。封控:RealIP 令牌桶(默认 5/s,与 login/refresh 共用)+ 同 target 60s 冷却,均42902— 见 docs/sms.md。 POST /v1/auth/login拿access_token/refresh_token。device_id由客户端生成并持久化,服务端不分配;写入 JWTdid。- 其它 HTTP:
Authorization: Bearer …,可选X-Device-ID(须等于登录时的 device_id)。 - 续期:
POST /v1/auth/token/refresh,Body{"refresh_token"},不要带 Bearer。 - 在线发消息、收推送、增量同步走 TCP。
POST /v1/messages只是 HTTP 调试通道,不要当 App 主路径。
测试号 test01@spark.com~test100@spark.com 可跳过发码,登录任意 verify_code。
uid 就是 Snowflake users.id。对外展示用可变的 spark_id,不要把 uid 写进对象存储路径。
演示种子(欢迎消息 / 机器人 / 示例群)
幂等命令(可重复执行):
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) |
| 认证 | /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 | → 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):
- TaskPool(禁止在 EventLoop 里做)
- 权限(好友 / 拉黑 / 群禁言)——失败则不 INSERT
- Snowflake MsgId(或客户端 MsgId,须在 ±5 分钟内)
- MySQL 事务:消息表 +
conversations.last_msg_id DeliverQueue.PushSEND_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。对象存储可空(上传接口会不可用)。
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。
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 8080127.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 |
代码规范 |