--- name: spark-im-v3 description: >- Implements Spark IM modular monolith per architecture V3. Use when writing or reviewing spark-server Go code, TCP/gnet, Gin HTTP, REST /v1 groups, MVC handlers, go-playground validator, RealIP, messages, deliver queue, inbox ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, MsgId, TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP, validator, LoginService, i18n, Accept-Language, zh-CN, uid, spark_id, infrastructure, entity, model, logic, enum, MessageType, oneof. --- # Spark IM V3 Read [invariants.md](invariants.md) before changing send/sync/transport. Full design: `docs/architecture-v3.md`. Lint: `.golangci.yaml`. ## Layout ```text cmd/server process entry cmd/migrate apply migrations/*.sql (re-runs all files) internal/bootstrap config, lifecycle, wire model → logic internal/transport/http Gin engine, REST groups, middleware internal/transport/http/handler thin MVC controllers (no business) internal/transport/http/respond BindJSON + envelope write internal/transport/tcp gnet EventLoop + TaskPool + Frame internal/module/{biz} entity / model / logic (see Layers) internal/pkg process-agnostic helpers (no DSN / pools) internal/infrastructure process adapters (mysql, redis, id, logger, push) ``` HTTP: read [http.md](http.md) — validator keys, `reqctx.RealIP`, `/v1/{resource}` groups, MVC login. i18n: read [i18n.md](i18n.md) — request language, error `msg`, notification types. Enums: read [enums.md](enums.md) — typed entity enums, OpenAPI `$ref`, `oneof` bound to the same numbers. ## Layers Each `internal/module/{biz}` is three packages. Do not put entity, GORM rows, and use-cases in one directory. | Layer | Path | Owns | |-------|------|------| | entity | `{biz}/entity` | Domain types, message payloads. No I/O. | | model | `{biz}/model` | One Go file per MySQL table (GORM + COMMENT). Redis adapters. | | logic | `{biz}/logic` | One file per **function** (profile, login, send). Not per table. | `logic` must not import `model`. Wire implementations in `internal/bootstrap`. Auth has no tables (entity + logic only). HTTP/TCP both call `logic`; Gin handlers only bind and write the envelope. ## pkg vs infrastructure Do **not** move `infrastructure` into `pkg` (or a public repo-root `pkg/`). | Path | Owns | May import | |------|------|------------| | `internal/pkg` | `errs`, `i18n`, `validate`, `reqctx`, `reply`, in-memory `ratelimit` | stdlib + other pkg only | | `internal/infrastructure` | MySQL pool, Redis client, Snowflake `id`, zap `logger`, push, metrics | pkg + vendor drivers | `pkg` has no connection lifecycle and must not know DSN, Redis AUTH, or `snowflake_worker_id`. `infrastructure` is opened/closed by `bootstrap`. Direction: infrastructure → pkg, never the reverse. ## IDs Not every PK is Snowflake. Do not AUTO_INCREMENT natural keys. | Kind | Examples | Rule | |------|----------|------| | Snowflake | `users.id` (uid), `messages.id` (MsgId), `conversations.id` | Time-ordered business IDs. One `id.Generator` per process; `snowflake_worker_id` unique per process. Clock rewind fails issuance for the whole process. | | AUTO_INCREMENT | `user_devices.id`, `groups.id` (群号), `friend_relations.id` | Internal row id, or sequential public 群号. Friend edges are directed (`user_id`→`friend_id`); UNIQUE `(user_id, friend_id)`. Business chat cursor is still Snowflake `conversations.id`. | | Natural key | `user_emails.email`, P2P `(user_small, user_big)`, members, sync cursors | Composite / natural PK. No extra surrogate id. | `device_id` is **client-generated**, stored on the device, written to JWT `did`. The server does not allocate it. `uid` in JSON **is** `users.id`; do not add a second uid column. ## Send path (must keep this order) 1. TaskPool (not EventLoop) 2. Permission (friend / block / group mute) — fail before INSERT 3. Snowflake MsgId (or client MsgId within ±5m) 4. MySQL txn: `messages` + `conversations.last_msg_id` 5. `DeliverQueue.Push` (reliable list) 6. `SEND_ACK` If step 5 fails, do not ACK. Client retries the same `client_msg_id`. ## EventLoop vs pools | Pool | Owns | |------|------| | gnet EventLoop | PROXY, TLS, Frame, heartbeat, PUSH_ACK, Submit | | TaskPool | AUTH, SEND, SYNC, READ, RECALL, MySQL | | Deliver Worker | ZSET inbox, Session.Send, mailbox on push fail, PushNotifier | ## Commands `AUTH HEARTBEAT GENERATE_MSG_ID SEND_MESSAGE SEND_ACK MESSAGE PUSH_ACK READ RECALL SYNC_INBOX SYNC_MESSAGES KICK` Media: HTTP upload credential, message content is object key only. Frame max 64KB. Compress body over 512B. Message types: text / image / voice / video / emoji / gif / file / location / system (`message/entity.Type` 1–9). `messages.content` stores serialized JSON bytes (BLOB), never a raw string. Closed type/status values: [enums.md](enums.md). TCP body is JSON envelope `{code,msg,request_id,data}`. AUTH request is `{"token","device_id","app_version","language"}` (not protobuf). Public TLS is Nginx `:443` SNI: HTTP cert on loopback `:8443`, IM cert on loopback `:19001` then PROXY to gnet `:9090` (host `:9000` is rustfs). See `deploy/nginx/`. AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Deliver workers start with `RequeueStale` + `SETNX deliver:done:{msgId}`. ## When adding a feature - HTTP writes must enqueue deliver jobs for online devices. - List/unread go to Redis ZSET, not MySQL row-per-member. - New goroutine I/O does not belong in `OnTraffic`. - HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context. - Closed type/status: entity typed enum + `Valid()` + OpenAPI named `$ref` + validator `oneof` on the same values ([enums.md](enums.md)). - Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`). - SQL tables and GORM models carry COMMENT on the table and every column. - After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy. - Run `make lint` (funlen 100). ## Additional resources - [invariants.md](invariants.md) - [send-path.md](send-path.md) - [http.md](http.md) - [i18n.md](i18n.md) - [enums.md](enums.md) - `docs/architecture-v3.md`