diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md index 0ba9a04..30fb772 100644 --- a/.cursor/skills/spark-im-v3/SKILL.md +++ b/.cursor/skills/spark-im-v3/SKILL.md @@ -6,7 +6,8 @@ description: >- 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. + i18n, Accept-Language, zh-CN, uid, spark_id, infrastructure, entity, model, logic, + enum, MessageType, oneof. --- # Spark IM V3 @@ -30,6 +31,7 @@ internal/infrastructure process adapters (mysql, redis, id, logger, pus 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 @@ -61,7 +63,7 @@ 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` (群号) | Internal row id, or sequential public 群号. Business chat cursor is still Snowflake `conversations.id`. | +| 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. @@ -90,10 +92,10 @@ If step 5 fails, do not ACK. Client retries the same `client_msg_id`. `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. `messages.content` stores serialized JSON bytes (BLOB), never a raw string. +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,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 `:9001` then PROXY to gnet `:9000`. See `deploy/nginx/`. +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 @@ -102,6 +104,7 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del - 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. @@ -113,4 +116,5 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del - [send-path.md](send-path.md) - [http.md](http.md) - [i18n.md](i18n.md) +- [enums.md](enums.md) - `docs/architecture-v3.md` diff --git a/.cursor/skills/spark-im-v3/enums.md b/.cursor/skills/spark-im-v3/enums.md new file mode 100644 index 0000000..999edea --- /dev/null +++ b/.cursor/skills/spark-im-v3/enums.md @@ -0,0 +1,95 @@ +# Enums: typed closed sets for JSON, TCP, and SQL + +Closed type/status values live in `internal/module/{biz}/entity` as named types. OpenAPI names the same set once and `$ref`s it. Do not invent a parallel string vocabulary. + +`GET /v1/meta/enums` is the client bootstrap (no auth). Human pages: `/docs/enums` (jumpable sets) and `/docs/errors` (`envelope_code`). `value` is the wire number or string; `name` is a stable English token; `label` is i18n for the request language. Values are built from entity consts — do not hand-copy numbers into the handler. + +Wire JSON and TCP stay **numbers** (or the existing strings below). Do not turn message types into strings. Do not change persisted numeric values. + +## Template (`message/entity.Type`) + +```go +type Type int16 + +const ( + TypeText Type = 1 + // ... + TypeSystem Type = 9 +) + +func (t Type) Valid() bool { + return t >= TypeText && t <= TypeSystem +} +``` + +Input DTOs that bind the field directly use the same numbers in `validate:"oneof=..."`: + +```go +Gender *entity.Gender `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"` +``` + +Nested JSON (e.g. `content.t`) is not a validator field; gate it with `Valid()` inside `UnmarshalContent` / `Content.Validate`. + +## Layers + +| Layer | Rule | +|-------|------| +| entity | `type X int` / `int16` / `string` + const + `Valid()`. Domain structs use the typed field. | +| model | GORM rows stay `int` / `int16` / `string`. Convert at `entity()` / `rowFrom`. Never store a Go typed enum in a GORM struct if the column is numeric. | +| logic JSON | Response/request DTOs use the entity type so encoding/json emits the number (or the string). | +| HTTP validator | `oneof` lists the **same** numbers or strings as the consts. i18n `errmsg` for `oneof`. | +| OpenAPI | One named schema in `components/schemas`; fields `$ref` it. Do not inline `enum:` on the field. | +| HTTP catalog | Add the set to `meta/logic.Catalog` and i18n `enum.{set}.{name}`. Served by `GET /v1/meta/enums`. | + +Name clashes: the JSON viewer `relation` is `RelationKind`; the table column is `EdgeStatus`. Do not name both `Status` in the same package if two tables differ. + +## Numeric catalog + +Values are stable. Do not reuse a retired number. + +| OpenAPI schema | Go | JSON / TCP | SQL | Values | +|----------------|----|------------|-----|--------| +| `Gender` | `user/entity.Gender` | `gender` | `users.gender` | 0未知 1男 2女 | +| `UserStatus` | `user/entity.Status` | `status` | `users.status` | 0停用 1正常 | +| `ConversationType` | `conversation/entity.Type` | (internal / conv type) | `conversations.type` | 1单聊 2群聊 | +| `MessageKind` | `message/entity.Kind` | `kind` | not a column; selects `private_message` vs `group_message` | 1 / 2, **same numbers as ConversationType** | +| `MessageType` | `message/entity.Type` | `message_type`, `content.t` | `*_message.message_type` | 1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统 | +| `MessageStatus` | `message/entity.Status` | `status` | `*_message.status` | 1正常 2已撤回 3审核隐藏 | +| `RelationKind` | `friend/entity.RelationKind` | `relation` | not a column (viewer-centric) | 0无 1好友 2已拉黑 | +| — | `friend/entity.EdgeStatus` | not on the wire | `friend_relations.status` | 0非好友 1好友 | +| `FriendRequestStatus` | `friend/entity.RequestStatus` | apply `status` | `friend_requests.status` | 1待处理 2已同意 3已拒绝 | +| `FriendSource` | `friend/entity.Source` | `source` | request + edge `source` | 0未知 1二维码 2邮箱 3spark_id | +| `ReportReason` | `friend/entity.ReportReason` | `reason` | `friend_reports.reason` | 1垃圾 2骚扰 3不实身份 4其他 | +| `GroupStatus` | `group/entity.Status` | (internal) | `groups.status` | 1正常 2已解散 | +| `GroupRole` | `group/entity.Role` | `role` | `group_members.role` | 1群主 2管理员 3成员。PATCH 只能 2 或 3 | +| `MailboxEventType` | `sync/entity.EventType` | mailbox `event_type` | `mailbox_events.event_type` | 1漏推 2撤回 3删除 | +| `TcpCommand` | `tcp/protocol.Cmd*` | Frame.Command | — | 1–15 see protocol | +| `EnvelopeCode` | `pkg/errs` codes | envelope `code` | — | `"0"` + `40001`… | + +`POST /v1/friends/requests` returns `FriendApplyData.status` as **1 or 2**, not `"pending"` / `"accepted"`. + +Compare `MessageKind` with `conversation.entity.Type`, never with `message.entity.Type`. + +## String catalog (keep as strings) + +Do not convert these to integers. + +| OpenAPI schema | Go | JSON | Values | +|----------------|----|------|--------| +| `DeviceType` | login DTO `string` + `oneof` | `device_type` / `platform` | `ios android harmony windows macos web` | +| `Language` | `i18n.Lang` | `language`, `Accept-Language`, `X-Language` | `zh-CN` `en` `zh-TW` (default `zh-CN`) | +| `UploadPurpose` | `upload/entity.Purpose*` consts + `LookupSpec` | `purpose` | `avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file` | +| `SystemKind` | `friend/entity.SystemKind*` / `group/entity.SystemKind*` | `content.system.kind` | `friend.became` `group.created` `group.invite` `group.leave` `group.kick` `group.dissolve` `group.transfer` | + +Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum. + +## Adding a closed set + +1. Put `type X int` (or keep a string const set) in the owning module `entity`. Add `Valid()`. +2. Use `X` on entity + JSON DTOs. Convert at the GORM boundary. +3. Bind request fields with `oneof=` listing the same values; add i18n `errmsg`. +4. Add a named schema in `internal/transport/http/docs/openapi.yaml` and `$ref` every field. +5. Add the set to `GET /v1/meta/enums` (`meta/logic`) and i18n labels `enum.{set}.{name}` (zh-CN / en / zh-TW). +6. Update this catalog. + +Do **not**: change existing numbers; stringify a numeric set; duplicate enum lists in handlers. diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md index 818fe0d..4ad78e0 100644 --- a/.cursor/skills/spark-im-v3/http.md +++ b/.cursor/skills/spark-im-v3/http.md @@ -8,7 +8,7 @@ HTTP and TCP share Application services in `internal/module/*`. Gin is a thin ad |-------|----------|------| | Model / application | `internal/module/{biz}/{entity,model,logic}` | Domain types; one GORM file per table; logic by function, not by table | | Controller | `internal/transport/http/handler/{biz}` | Bind, call service, write envelope | -| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,data}` | +| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,request_id,data}` | Handlers must not contain business rules (credential check, token issue, friend/group permission, persist). Example: login belongs on `auth/logic.LoginService`, not in the Gin handler. @@ -78,21 +78,22 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans | Group | Prefix | Examples | |-------|--------|----------| -| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI | +| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) | | auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | | users | `/v1/users` | `GET/PATCH /v1/users/me` | -| friends | `/v1/friends` | apply, requests, accept, profile, remark, block, report | +| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report | | groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve | | conversations | `/v1/conversations` | list, mute | | messages | `/v1/messages` | history (sync still prefers TCP) | | files | `/v1/files` | upload credential | +| meta | `/v1/meta` | `GET /v1/meta/enums`(无需登录) | Rules: - Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP. - Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`. -- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs). -- OpenAPI `tags` must match these groups. +- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`). +- OpenAPI `tags` must match these groups. Docs jump tags `enums` / `errors` sit above `system` and open `/docs/enums`、`/docs/errors`. ## Checklist when adding an HTTP API @@ -103,3 +104,4 @@ Rules: 5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`. 6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit. 7. Localize errors/notifications via `internal/pkg/i18n`; never hardcode `msg`. +8. Closed type/status: entity typed enum + OpenAPI named schema `$ref` ([enums.md](enums.md)). Do not inline a second `enum:` list. diff --git a/.cursor/skills/spark-im-v3/invariants.md b/.cursor/skills/spark-im-v3/invariants.md index 9006d58..b77d298 100644 --- a/.cursor/skills/spark-im-v3/invariants.md +++ b/.cursor/skills/spark-im-v3/invariants.md @@ -5,7 +5,7 @@ | C1 | MySQL + Snowflake MsgId. No ConversationSeq. | | C2 | EventLoop ≠ MySQL. Fanout = Redis list + worker (`BRPOPLPUSH`). | | C3 | TLS + thin Frame. No custom handshake crypto. | -| C4 | Public 443 / Nginx SNI. gnet is `127.0.0.1:9000`. | +| C4 | Public 443 / Nginx SNI. gnet/Gin bind loopback only. | | C5 | ACK = COMMIT + enqueue. | | C6 | PROXY RealIP for limit/audit. | | C7 | Dialog list in Redis ZSET. Mailbox only for missed push + control events. | diff --git a/.golangci.yaml b/.golangci.yaml index 90ed6aa..5f65639 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -5,7 +5,7 @@ run: tests: true go: "1.26" -linters_settings: +linters-settings: funlen: lines: 100 statements: 100 @@ -87,6 +87,7 @@ issues: - dupl - funlen - goconst - - path: internal/module/.*/entity\.go + - path: internal/pkg/i18n/catalog_ linters: - - unused + - dupl + - goconst diff --git a/Dockerfile b/Dockerfile index 85d62f3..bf48796 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ COPY migrations /app/migrations COPY deploy/docker/config.docker.yaml /app/configs/config.yaml RUN mkdir -p /app/logs && chown -R spark:spark /app USER spark -EXPOSE 8080 9000 +EXPOSE 8080 9090 HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 ENTRYPOINT ["/usr/local/bin/spark-server"] diff --git a/README.md b/README.md index b66f76b..409ee41 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ Spark IM modular monolith. Architecture: [docs/architecture-v3.md](docs/architecture-v3.md). - Go 1.26 -- Gin HTTP on `127.0.0.1:8080`. JSON envelope `{"code","msg","data"}`. Docs: `GET /docs`, spec: `GET /openapi.yaml`. +- Gin HTTP on `127.0.0.1:8080`. JSON envelope `{"code","msg","request_id","data"}`. Docs: `GET /docs` (API), `/docs/enums`, `/docs/errors`. Spec: `GET /openapi.yaml`. - Probes: `GET /healthz` liveness; `GET /readyz` readiness (HTTP 503 when MySQL/Redis/task pool/accepting are not ready). `env: prod` (or `SPARK_ENV=prod`) rejects empty MySQL DSN, empty Redis, and the default JWT secret. -- Logs: zap to console and/or rotating file (`log.console` / `log.file`). GORM SQL goes through the same zap. `trace_id` / `request_id` follow context (`X-Request-ID` / `X-Trace-ID`). -- gnet TCP on `127.0.0.1:9000` (EventLoop + TaskPool). gnet v2.10 has no built-in TLS; Nginx terminates IM TLS and forwards PROXY+plaintext. Sample: `deploy/nginx/`. -- TCP Frame body is JSON `{code,msg,data}`. AUTH body: `{"token","device_id","app_version","language"}`. +- Logs: zap to console and/or rotating file (`log.console` / `log.file`). GORM SQL goes through the same zap. `request_id` is server-generated and returned in the response body (also as `X-Request-ID`). +- gnet TCP on loopback (local `127.0.0.1:8090`; ECS Docker `127.0.0.1:9090` because host `:9000` is rustfs). gnet v2.10 has no built-in TLS; Nginx terminates IM TLS and forwards PROXY+plaintext. Sample: `deploy/nginx/`. `make deploy` writes image/config/logs under `/data/spark-server/`. +- TCP Frame body is JSON `{code,msg,request_id,data}`. AUTH body: `{"token","device_id","app_version","language"}`. - Public entry is Nginx `:443` only (SNI split) ```bash diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 95e0dd7..5baacec 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -12,6 +12,7 @@ import ( mysqldriver "github.com/go-sql-driver/mysql" "go.uber.org/zap" + "gorm.io/gorm" "spark-server/internal/bootstrap" "spark-server/internal/infrastructure/mysql" @@ -74,6 +75,10 @@ func run(cfgPath, dir string) error { gdb := db.Gorm() ctx := context.Background() for _, path := range files { + if skipFriendDirected(gdb, path) { + fmt.Printf("skip %s\n", filepath.Base(path)) + continue + } raw, err := os.ReadFile(path) if err != nil { return err @@ -120,10 +125,21 @@ func ignorableDDL(err error) bool { } switch me.Number { case 1050, // table already exists + 1051, // unknown table 1060, // duplicate column - 1061: // duplicate key name + 1061, // duplicate key name + 1146: // table doesn't exist return true default: return false } } + +func skipFriendDirected(gdb *gorm.DB, path string) bool { + if filepath.Base(path) != "0013_friend_directed.sql" || gdb == nil { + return false + } + var n int64 + err := gdb.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'friend_relations' AND column_name = 'user_id'`).Scan(&n).Error + return err == nil && n > 0 +} diff --git a/configs/config.yaml b/configs/config.yaml index 5336420..89c7364 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -32,7 +32,7 @@ log: compress: true mysql: - dsn: "" + dsn: "spark:zpQxymHedkYrr8dK@tcp(47.236.66.9:3307)/spark?charset=utf8mb4&parseTime=True&loc=Local" slow_threshold: 200ms log_level: "" max_open_conns: 64 @@ -41,9 +41,9 @@ mysql: conn_max_idle_time: 10m redis: - addr: "" + addr: "47.236.66.9:6379" username: "" - password: "" + password: "kL4co8TEaE68vnMk" db: 0 deliver_queue_key: "deliver:queue" deliver_processing_key: "deliver:processing" @@ -83,9 +83,9 @@ message: msgid_expire_ms: 300000 object_storage: - endpoint: "" - access_key: "" - secret_key: "" + endpoint: "47.236.66.9:9000" + access_key: "rustfs_spark" + secret_key: 'kWvGtYT/~q+35q;3dSI<^P!MH+bgrpBg' bucket: "spark" region: "us-east-1" use_ssl: false diff --git a/deploy/docker/config.docker.yaml b/deploy/docker/config.docker.yaml index b4bf1a3..7ded671 100644 --- a/deploy/docker/config.docker.yaml +++ b/deploy/docker/config.docker.yaml @@ -4,7 +4,7 @@ server: http: addr: "0.0.0.0:8080" tcp: - addr: "0.0.0.0:9000" + addr: "0.0.0.0:9090" proxy_protocol: true tls: cert_file: "" @@ -32,7 +32,7 @@ log: compress: true mysql: - dsn: "spark:@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local" + dsn: "spark:zpQxymHedkYrr8dK@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local" slow_threshold: 200ms log_level: "" max_open_conns: 64 @@ -50,7 +50,7 @@ redis: deliver_done_prefix: "deliver:done:" jwt: - secret: "dev-only-change-me" + secret: "0e624dce0ebe9115c2eb048646a141ccbd88366e7f2c2718065bc3a3838dda95" issuer: "spark-im" ttl: 168h refresh_ttl: 720h @@ -84,8 +84,8 @@ message: object_storage: endpoint: "rustfs:9000" - access_key: "" - secret_key: "" + access_key: "rustfs_spark" + secret_key: 'kWvGtYT/~q+35q;3dSI<^P!MH+bgrpBg' bucket: "spark" region: "us-east-1" use_ssl: false diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index f6efda2..04c6afc 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1,13 +1,21 @@ #!/usr/bin/env bash # Deploy spark-server to the ECS host and join mysql/redis/rustfs on shell_default. +# Host layout: +# /data/spark-server/app source (docker build context) +# /data/spark-server/configs config.yaml +# /data/spark-server/logs process logs +# /data/spark-server/images docker save of spark-server:latest set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" HOST="${SPARK_SSH_HOST:-root@47.236.66.9}" KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}" -REMOTE="${SPARK_REMOTE_DIR:-/opt/spark-server}" +DATA="${SPARK_DATA_DIR:-/data/spark-server}" +REMOTE="${SPARK_REMOTE_DIR:-$DATA/app}" SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST") +"${SSH[@]}" "mkdir -p $DATA/app $DATA/configs $DATA/logs $DATA/images" + rsync -az --delete \ --exclude '.git/' \ --exclude 'bin/' \ @@ -20,9 +28,11 @@ rsync -az --delete \ -e "ssh -i $KEY -o BatchMode=yes" \ "$ROOT/" "$HOST:$REMOTE/" -"${SSH[@]}" "mkdir -p /data/spark-server/logs && chown -R 10001:10001 /data/spark-server/logs" +"${SSH[@]}" "cp $REMOTE/deploy/docker/config.docker.yaml $DATA/configs/config.yaml && chown -R 10001:10001 $DATA/logs" "${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml build" +"${SSH[@]}" "docker save spark-server:latest | gzip -c > $DATA/images/spark-server.tar.gz" "${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml up -d spark-server" "${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations" +"${SSH[@]}" "cp $REMOTE/deploy/nginx/host/spark-sni.conf /etc/nginx/stream.d/spark-sni.conf && nginx -t && systemctl reload nginx" "${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" "${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo" diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index a58be45..03a7e6b 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -1,4 +1,6 @@ # Joins the existing mysql/redis/rustfs network (docker compose project "shell"). +# Host :9000 is rustfs S3. gnet is loopback :9090. +# Image/config/logs live under /data/spark-server on the ECS host. services: spark-server: build: @@ -11,10 +13,10 @@ services: TZ: Asia/Shanghai ports: - "127.0.0.1:18080:8080" - - "127.0.0.1:19000:9000" + - "127.0.0.1:9090:9090" volumes: - /data/spark-server/logs:/app/logs - - ./config.docker.yaml:/app/configs/config.yaml:ro + - /data/spark-server/configs/config.yaml:/app/configs/config.yaml:ro networks: - shell diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index e5658e3..05b242a 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -7,7 +7,7 @@ Client --TLS--> :443 ssl_preread ├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS) │ → proxy_pass http://127.0.0.1:18080 + X-Real-IP └─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS) - → PROXY protocol → 127.0.0.1:19000 gnet + → PROXY protocol → 127.0.0.1:9090 gnet ``` On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/etc/nginx/nginx.conf`. Drop-ins: @@ -15,12 +15,13 @@ On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/ - `host/sparkapi.conf` → `/etc/nginx/conf.d/` - `host/spark-sni.conf` → `/etc/nginx/stream.d/` -Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443 and `$remote_addr` stays the client IP. +Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443. `00-spark-http.conf` sets `real_ip_header proxy_protocol` and `port_in_redirect off` so `$remote_addr` is the client and redirects stay on 443. spark-server (Docker): - HTTP `127.0.0.1:18080` → container `:8080` -- TCP `127.0.0.1:19000` with `proxy_protocol: true` → container `:9000` +- TCP `127.0.0.1:9090` with `proxy_protocol: true` → container `:9090` (host `:9000` is rustfs S3) +- Image / config / logs: `/data/spark-server/{images,configs,logs}` - Public security group: 443 only ```bash diff --git a/deploy/nginx/host/00-spark-http.conf b/deploy/nginx/host/00-spark-http.conf new file mode 100644 index 0000000..d2403ca --- /dev/null +++ b/deploy/nginx/host/00-spark-http.conf @@ -0,0 +1,6 @@ +# Shared HTTPS after stream ssl_preread: restore client IP, hide :8443 in redirects. +set_real_ip_from 127.0.0.1; +set_real_ip_from ::1; +real_ip_header proxy_protocol; +port_in_redirect off; +absolute_redirect off; diff --git a/deploy/nginx/host/spark-sni.conf b/deploy/nginx/host/spark-sni.conf index feeff37..38cc23a 100644 --- a/deploy/nginx/host/spark-sni.conf +++ b/deploy/nginx/host/spark-sni.conf @@ -1,5 +1,5 @@ # Spark IM + shared HTTPS on :443 (ssl_preread). -# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :19000 +# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :9090 # default SNI → existing HTTP sites on 127.0.0.1:8443 (proxy_protocol restores RealIP) map $ssl_preread_server_name $spark_sni_backend { @@ -9,7 +9,7 @@ map $ssl_preread_server_name $spark_sni_backend { server { listen 443 reuseport; - listen [::]:443 reuseport; + listen [::]:443 reuseport ipv6only=on; ssl_preread on; proxy_connect_timeout 5s; proxy_timeout 3600s; @@ -25,5 +25,5 @@ server { proxy_connect_timeout 5s; proxy_timeout 3600s; proxy_protocol on; - proxy_pass 127.0.0.1:19000; + proxy_pass 127.0.0.1:9090; } diff --git a/deploy/nginx/install-host.sh b/deploy/nginx/install-host.sh index e4eef41..51a9371 100755 --- a/deploy/nginx/install-host.sh +++ b/deploy/nginx/install-host.sh @@ -48,8 +48,15 @@ rewrite_http_443() { install_sni() { rewrite_http_443 rm -f /etc/nginx/conf.d/spark-acme.conf + cp "$HOST_DIR/00-spark-http.conf" /etc/nginx/conf.d/00-spark-http.conf cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf + mkdir -p /etc/letsencrypt/renewal-hooks/deploy + cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK' +#!/bin/sh +systemctl reload nginx +HOOK + chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh if grep -RInE 'listen[[:space:]]+(\[::\]:)?443[[:space:]]+ssl' /etc/nginx/conf.d; then echo "http still binds public 443" >&2 exit 1 diff --git a/docs/architecture-v3.md b/docs/architecture-v3.md index 945c582..182fda4 100644 --- a/docs/architecture-v3.md +++ b/docs/architecture-v3.md @@ -289,7 +289,7 @@ TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope ```text Magic u16 | Version u8 | Command u16 | Flags u16 RequestID u64 | SequenceID u32 | BodyLength u32 -JSON Body {"code","msg","data"} +JSON Body {"code","msg","request_id","data"} ``` Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。 diff --git a/docs/clients/SparkImClient.java b/docs/clients/SparkImClient.java new file mode 100644 index 0000000..01e1e85 --- /dev/null +++ b/docs/clients/SparkImClient.java @@ -0,0 +1,126 @@ +// Spark IM HTTP + TCP demo against production. +// javac docs/clients/SparkImClient.java && java -cp docs/clients SparkImClient +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.GZIPInputStream; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +public final class SparkImClient { + static final String API = "https://sparkapi.wyrs365.com"; + static final String IM_HOST = "sparkim.wyrs365.com"; + static final int IM_PORT = 443; + static final int HDR = 23; + static final long DEVICE_ID = 7; + + public static void main(String[] args) throws Exception { + String login = httpPost(API + "/v1/auth/login", + "{\"email\":\"test01@spark.com\",\"verify_code\":\"000000\",\"device_id\":" + + DEVICE_ID + ",\"device_type\":\"android\",\"device_name\":\"demo\"}"); + System.out.println("LOGIN " + login); + if (!login.contains("\"code\":\"0\"")) { + return; + } + String token = jsonString(login, "access_token"); + + SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault(); + SSLSocket socket = (SSLSocket) factory.createSocket(); + SSLParameters params = socket.getSSLParameters(); + params.setServerNames(List.of(new SNIHostName(IM_HOST))); + socket.setSSLParameters(params); + socket.connect(new InetSocketAddress(IM_HOST, IM_PORT), 10_000); + socket.startHandshake(); + DataOutputStream out = new DataOutputStream(socket.getOutputStream()); + DataInputStream in = new DataInputStream(socket.getInputStream()); + + String auth = "{\"token\":\"" + token + "\",\"device_id\":" + DEVICE_ID + + ",\"app_version\":\"1.0.0\",\"language\":\"zh-CN\"}"; + write(out, 1, 1, auth); + System.out.println("AUTH " + read(in)); + write(out, 2, 2, ""); + System.out.println("HB " + read(in)); + write(out, 4, 3, "{\"peer_id\":1,\"client_msg_id\":\"demo-1\",\"content\":{\"t\":1,\"text\":{\"body\":\"hi\"}}}"); + System.out.println("SEND " + read(in)); + socket.close(); + } + + static void write(DataOutputStream out, int cmd, int seq, String body) throws IOException { + byte[] b = body.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocate(HDR + b.length); + buf.putShort((short) 0x5350).put((byte) 1).putShort((short) cmd).putShort((short) 0) + .putLong(0).putInt(seq).putInt(b.length).put(b); + out.write(buf.array()); + out.flush(); + } + + static String read(DataInputStream in) throws IOException { + byte[] h = in.readNBytes(HDR); + if (h.length < HDR) { + throw new IOException("short header"); + } + ByteBuffer buf = ByteBuffer.wrap(h); + if (buf.getShort() != 0x5350) { + throw new IOException("magic"); + } + buf.get(); + int cmd = buf.getShort() & 0xffff; + int flags = buf.getShort() & 0xffff; + buf.getLong(); + int seq = buf.getInt(); + int n = buf.getInt(); + byte[] body = in.readNBytes(n); + if ((flags & 1) != 0) { + body = gunzip(body); + } + return "cmd=" + cmd + " seq=" + seq + " " + new String(body, StandardCharsets.UTF_8); + } + + static byte[] gunzip(byte[] body) throws IOException { + try (GZIPInputStream gz = new GZIPInputStream(new ByteArrayInputStream(body)); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + gz.transferTo(bos); + return bos.toByteArray(); + } + } + + static String httpPost(String url, String json) throws IOException { + URL u = URI.create(url).toURL(); + HttpURLConnection c = (HttpURLConnection) u.openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + c.setRequestProperty("X-Language", "zh-CN"); + c.setRequestProperty("X-App-Version", "1.0.0"); + try (OutputStream os = c.getOutputStream()) { + os.write(json.getBytes(StandardCharsets.UTF_8)); + } + int code = c.getResponseCode(); + InputStream is = code >= 400 ? c.getErrorStream() : c.getInputStream(); + try (InputStream in = is) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + static String jsonString(String env, String key) { + int i = env.indexOf("\"" + key + "\""); + int colon = env.indexOf(':', i); + int a = env.indexOf('"', colon) + 1; + return env.substring(a, env.indexOf('"', a)); + } + + private SparkImClient() {} +} diff --git a/docs/clients/SparkImClient.swift b/docs/clients/SparkImClient.swift new file mode 100644 index 0000000..2095da6 --- /dev/null +++ b/docs/clients/SparkImClient.swift @@ -0,0 +1,121 @@ +// Spark IM HTTP + TCP demo against production (iOS 13+ / macOS). +// Drop into a Swift command-line target or iOS app and call SparkImClient.run(). +import Foundation +import Network + +enum SparkImClient { + static let api = URL(string: "https://sparkapi.wyrs365.com")! + static let imHost = NWEndpoint.Host("sparkim.wyrs365.com") + static let imPort = NWEndpoint.Port(443)! + static let deviceID: UInt64 = 7 + + struct LoginEnv: Decodable { + struct Data: Decodable { let access_token: String } + let code: String + let data: Data + } + + static func run() { + do { + let token = try login() + connect(token: token) + RunLoop.main.run(until: Date().addingTimeInterval(15)) + } catch { + fputs("login failed: \(error)\n", stderr) + } + } + + static func login() throws -> String { + var req = URLRequest(url: api.appendingPathComponent("/v1/auth/login")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("zh-CN", forHTTPHeaderField: "X-Language") + req.setValue("1.0.0", forHTTPHeaderField: "X-App-Version") + req.httpBody = """ + {"email":"test01@spark.com","verify_code":"000000","device_id":\(deviceID),"device_type":"ios","device_name":"demo"} + """.data(using: .utf8) + let sem = DispatchSemaphore(value: 0) + var result: Result = .failure(URLError(.unknown)) + URLSession.shared.dataTask(with: req) { data, _, err in + defer { sem.signal() } + if let err { + result = .failure(err) + return + } + guard let data else { + result = .failure(URLError(.badServerResponse)) + return + } + do { + let env = try JSONDecoder().decode(LoginEnv.self, from: data) + guard env.code == "0" else { + result = .failure(URLError(.userAuthenticationRequired)) + return + } + result = .success(env.data.access_token) + } catch { + result = .failure(error) + } + }.resume() + sem.wait() + return try result.get() + } + + static func connect(token: String) { + let conn = NWConnection(host: imHost, port: imPort, using: .tls) + conn.stateUpdateHandler = { state in + guard case .ready = state else { return } + let auth = #"{"token":"\#(token)","device_id":\#(deviceID),"app_version":"1.0.0","language":"zh-CN"}"# + send(conn, cmd: 1, seq: 1, body: Data(auth.utf8)) { + recv(conn) { authResp in + print("AUTH", authResp) + send(conn, cmd: 2, seq: 2, body: Data()) { + recv(conn) { hb in + print("HB", hb) + let sendBody = #"{"peer_id":1,"client_msg_id":"demo-1","content":{"t":1,"text":{"body":"hi"}}}"# + send(conn, cmd: 4, seq: 3, body: Data(sendBody.utf8)) { + recv(conn) { ack in + print("SEND", ack) + } + } + } + } + } + } + } + conn.start(queue: .main) + } + + static func frame(cmd: UInt16, seq: UInt32, body: Data) -> Data { + var d = Data(capacity: 23 + body.count) + func u16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xff)) } + func u32(_ v: UInt32) { + d.append(UInt8((v >> 24) & 0xff)); d.append(UInt8((v >> 16) & 0xff)) + d.append(UInt8((v >> 8) & 0xff)); d.append(UInt8(v & 0xff)) + } + u16(0x5350); d.append(1); u16(cmd); u16(0) + d.append(contentsOf: [UInt8](repeating: 0, count: 8)) + u32(seq); u32(UInt32(body.count)); d.append(body) + return d + } + + static func send(_ conn: NWConnection, cmd: UInt16, seq: UInt32, body: Data, done: @escaping () -> Void) { + conn.send(content: frame(cmd: cmd, seq: seq, body: body), completion: .contentProcessed { _ in done() }) + } + + static func recv(_ conn: NWConnection, done: @escaping (String) -> Void) { + conn.receive(minimumIncompleteLength: 23, maximumLength: 23) { header, _, _, err in + guard let header, header.count == 23, err == nil else { return } + let n = Int(header[19]) << 24 | Int(header[20]) << 16 | Int(header[21]) << 8 | Int(header[22]) + let cmd = Int(header[3]) << 8 | Int(header[4]) + if n == 0 { + done("cmd=\(cmd) {}") + return + } + conn.receive(minimumIncompleteLength: n, maximumLength: max(n, 1)) { body, _, _, _ in + let payload = body ?? Data() + done("cmd=\(cmd) " + (String(data: payload, encoding: .utf8) ?? "")) + } + } + } +} diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 13e22b1..4d7892b 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -31,7 +31,12 @@ import ( friendmodel "spark-server/internal/module/friend/model" grouplogic "spark-server/internal/module/group/logic" groupmodel "spark-server/internal/module/group/model" + inboxlogic "spark-server/internal/module/inbox/logic" + inboxmodel "spark-server/internal/module/inbox/model" + msglogic "spark-server/internal/module/message/logic" msgmodel "spark-server/internal/module/message/model" + synclogic "spark-server/internal/module/sync/logic" + syncmodel "spark-server/internal/module/sync/model" uploadlogic "spark-server/internal/module/upload/logic" userlogic "spark-server/internal/module/user/logic" usermodel "spark-server/internal/module/user/model" @@ -154,11 +159,15 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge login := authlogic.NewLoginService(jwtSvc, users) login.Sessions = sessions var deliverQueue deliverlogic.Queue - app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions) - friends := wireFriends(idgen, db, users, deliverQueue) - groups := wireGroups(idgen, db, users, friends, deliverQueue, objects) + inbox, mailbox := wireInbox(idgen, db, rdb) + app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions, inbox, mailbox) + syncSvc := synclogic.NewService(inbox, mailbox) + chat := wireChat(idgen, db) + friends := wireFriends(idgen, db, users, deliverQueue, chat) + groups := wireGroups(idgen, db, users, friends, deliverQueue, objects, chat) + messages := wireMessages(idgen, friends, groups, deliverQueue, chat) files := wireFiles(cfg, objects, idgen, groups) - favorites := wireFavorites(idgen, db, objects) + favorites := wireFavorites(idgen, db, objects, chat) app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ Addr: cfg.Server.TCP.Addr, Multicore: cfg.Server.TCP.Multicore, @@ -175,6 +184,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge IDs: idgen, Auth: jwtSvc, AuthLimiter: authLimit, + Messages: messages, + Sync: syncSvc, }) app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{ Auth: jwtSvc, @@ -184,6 +195,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge Groups: groups, Files: files, Favorites: favorites, + Messages: messages, + Sync: syncSvc, Health: probe, AuthLimiter: authLimit, }) @@ -221,37 +234,46 @@ func wireUsers(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client, store *obj return userlogic.NewService(repo, codes, idgen, objects) } -func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue) *friendlogic.Service { - var repo friendlogic.Repository - var chats friendlogic.P2PChats - var msgs friendlogic.Messages - if db != nil && db.Ready() { - repo = friendmodel.NewRepo(db.Gorm()) - chats = convlogic.NewP2PService(convmodel.NewRepo(db.Gorm()), idgen) - msgs = msgmodel.NewRepo(db.Gorm()) - } else { - repo = friendmodel.NewMemoryRepo() - chats = convlogic.NewP2PService(convmodel.NewMemoryP2P(), idgen) - msgs = msgmodel.NewMemoryRepo() - } - return friendlogic.NewService(repo, users, idgen, friendlogic.NewGreeter(chats, msgs, idgen, q)) +type chatStores struct { + msgs msglogic.Repository + chats msglogic.Conversations + p2p *convlogic.P2PService } -func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, friends *friendlogic.Service, q grouplogic.DeliverQueue, store *objectstore.Store) *grouplogic.Service { +func wireChat(idgen *id.Generator, db *mysql.DB) chatStores { + if idgen == nil { + return chatStores{} + } + if db != nil && db.Ready() { + convRepo := convmodel.NewRepo(db.Gorm()) + return chatStores{msgs: msgmodel.NewRepo(db.Gorm()), chats: convRepo, p2p: convlogic.NewP2PService(convRepo, idgen)} + } + mem := convmodel.NewMemoryP2P() + return chatStores{msgs: msgmodel.NewMemoryRepo(), chats: mem, p2p: convlogic.NewP2PService(mem, idgen)} +} + +func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue, chat chatStores) *friendlogic.Service { + var repo friendlogic.Repository + if db != nil && db.Ready() { + repo = friendmodel.NewRepo(db.Gorm()) + } else { + repo = friendmodel.NewMemoryRepo() + } + return friendlogic.NewService(repo, users, idgen, friendlogic.NewGreeter(chat.p2p, chat.msgs, idgen, q)) +} + +func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, friends *friendlogic.Service, q grouplogic.DeliverQueue, store *objectstore.Store, chat chatStores) *grouplogic.Service { var repo grouplogic.Repository - var msgs grouplogic.Messages if db != nil && db.Ready() { repo = groupmodel.NewRepo(db.Gorm()) - msgs = msgmodel.NewRepo(db.Gorm()) } else { repo = groupmodel.NewMemoryRepo() - msgs = msgmodel.NewMemoryRepo() } var objects grouplogic.Objects if store != nil && store.Ready() { objects = store } - return grouplogic.NewService(repo, users, friends, idgen, msgs, q, objects) + return grouplogic.NewService(repo, users, friends, idgen, chat.msgs, q, objects) } func wireFiles(cfg *Config, store *objectstore.Store, idgen *id.Generator, groups *grouplogic.Service) *uploadlogic.Service { @@ -265,30 +287,40 @@ func wireFiles(cfg *Config, store *objectstore.Store, idgen *id.Generator, group }) } -func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store) *favlogic.Service { +func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, chat chatStores) *favlogic.Service { if idgen == nil { return nil } var repo favlogic.Repository - var msgs favlogic.Messages - var members favlogic.Members if db != nil && db.Ready() { repo = favmodel.NewRepo(db.Gorm()) - msgs = msgmodel.NewRepo(db.Gorm()) - members = convmodel.NewRepo(db.Gorm()) } else { repo = favmodel.NewMemoryRepo() - msgs = msgmodel.NewMemoryRepo() - members = convmodel.NewMemoryP2P() } var objects favlogic.Objects if store != nil && store.Ready() { objects = store } - return favlogic.NewService(repo, msgs, members, objects, idgen) + return favlogic.NewService(repo, chat.msgs, chat.chats, objects, idgen) } -func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions session.Manager) (*deliverlogic.Worker, deliverlogic.Queue) { +func wireInbox(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (inboxlogic.Store, synclogic.Mailbox) { + var inbox inboxlogic.Store + if rdb != nil && rdb.Ready() { + inbox = inboxmodel.NewRedisStore(rdb.RDB()) + } else { + inbox = inboxmodel.NewMemoryStore() + } + var box synclogic.Mailbox + if db != nil && db.Ready() { + box = syncmodel.NewStore(db.Gorm(), idgen) + } else { + box = syncmodel.NewMemoryStore(idgen) + } + return inbox, box +} + +func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions session.Manager, inbox inboxlogic.Store, mailbox deliverlogic.Mailbox) (*deliverlogic.Worker, deliverlogic.Queue) { var q deliverlogic.Queue var done deliverlogic.Deduper if rdb != nil && rdb.Ready() { @@ -301,10 +333,17 @@ func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions sess return deliverlogic.NewWorker(log, q, done, deliverlogic.WorkerOptions{ Workers: cfg.Deliver.Workers, TakeTimeout: cfg.Deliver.TakeTimeout, - Handler: deliverlogic.SessionFanout(sessions), + Handler: deliverlogic.InboxFanout(sessions, inbox, mailbox), }), q } +func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *grouplogic.Service, q msglogic.DeliverQueue, chat chatStores) *msglogic.Service { + if idgen == nil { + return nil + } + return msglogic.NewService(chat.msgs, chat.chats, chat.p2p, friends, groups, idgen, q) +} + func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist { if rdb != nil && rdb.Ready() { return authlogic.NewRedisDenylist(rdb.RDB(), prefix) diff --git a/internal/infrastructure/id/id.go b/internal/infrastructure/id/id.go index 62003e8..2cfd073 100644 --- a/internal/infrastructure/id/id.go +++ b/internal/infrastructure/id/id.go @@ -44,3 +44,25 @@ func (g *Generator) NextID() (int64, error) { } func (g *Generator) WorkerID() int64 { return g.workerID } + +const DefaultMsgIDSkew = 5 * time.Minute + +// IssuedAtMS is the Snowflake unix-ms timestamp encoded in id. +func IssuedAtMS(id int64) int64 { + if id <= 0 { + return 0 + } + return snowflake.ID(id).Time() +} + +// WithinSkew reports whether id's timestamp is within skew of now (SEND client MsgId ±5m). +func WithinSkew(id int64, now time.Time, skew time.Duration) bool { + if id <= 0 || skew <= 0 { + return false + } + delta := now.UnixMilli() - IssuedAtMS(id) + if delta < 0 { + delta = -delta + } + return delta <= skew.Milliseconds() +} diff --git a/internal/infrastructure/id/id_test.go b/internal/infrastructure/id/id_test.go new file mode 100644 index 0000000..5b1f26e --- /dev/null +++ b/internal/infrastructure/id/id_test.go @@ -0,0 +1,26 @@ +package id + +import ( + "testing" + "time" +) + +func TestWithinSkew(t *testing.T) { + g, err := New(1) + if err != nil { + t.Fatal(err) + } + nid, err := g.NextID() + if err != nil { + t.Fatal(err) + } + if !WithinSkew(nid, time.Now(), DefaultMsgIDSkew) { + t.Fatal("fresh snowflake") + } + if WithinSkew(1, time.Now(), DefaultMsgIDSkew) { + t.Fatal("tiny id") + } + if WithinSkew(nid, time.Now().Add(10*time.Minute), DefaultMsgIDSkew) { + t.Fatal("outside window") + } +} diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go index cce1ceb..a2f061d 100644 --- a/internal/infrastructure/logger/logger.go +++ b/internal/infrastructure/logger/logger.go @@ -26,7 +26,7 @@ func From(ctx context.Context) *zap.Logger { return zap.NewNop() } -// Attach stores a logger on ctx with trace_id / request_id from RequestContext. +// Attach stores a logger on ctx with request_id from RequestContext. func Attach(ctx context.Context, base *zap.Logger) context.Context { if base == nil { base = zap.NewNop() @@ -42,9 +42,6 @@ func WithReq(log *zap.Logger, rc *reqctx.RequestContext) *zap.Logger { return log } fields := make([]zap.Field, 0, 8) - if rc.TraceID != "" { - fields = append(fields, zap.String("trace_id", rc.TraceID)) - } if rc.RequestID != "" { fields = append(fields, zap.String("request_id", rc.RequestID)) } diff --git a/internal/module/conversation/entity/conversation.go b/internal/module/conversation/entity/conversation.go index c1f0795..b828895 100644 --- a/internal/module/conversation/entity/conversation.go +++ b/internal/module/conversation/entity/conversation.go @@ -1,15 +1,22 @@ // Package entity holds conversation domain types. package entity +// Type is persisted as conversations.type. +type Type int + const ( - TypeP2P = 1 - TypeGroup = 2 + TypeP2P Type = 1 + TypeGroup Type = 2 ) +func (t Type) Valid() bool { + return t == TypeP2P || t == TypeGroup +} + // Conversation is a P2P or group chat. LastMsgID is the Snowflake cursor, not ConversationSeq. type Conversation struct { ID int64 - Type int + Type Type LastMsgID int64 LastMsgAt int64 } diff --git a/internal/module/conversation/model/conversation_member.go b/internal/module/conversation/model/conversation_member.go index 2526533..ae84452 100644 --- a/internal/module/conversation/model/conversation_member.go +++ b/internal/module/conversation/model/conversation_member.go @@ -31,6 +31,25 @@ func (r *ConversationMember) entity() *entity.Member { return m } +func (m *Repo) ListMemberIDs(ctx context.Context, conversationID int64) ([]int64, error) { + if conversationID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []ConversationMember + if err := db.Where("conversation_id = ?", conversationID).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + ids := make([]int64, 0, len(rows)) + for i := range rows { + ids = append(ids, rows[i].UserID) + } + return ids, nil +} + func (m *Repo) GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) { if conversationID <= 0 || userID <= 0 { return nil, errs.ErrInvalidArgument diff --git a/internal/module/conversation/model/memory.go b/internal/module/conversation/model/memory.go index 27cc406..0a6d5c3 100644 --- a/internal/module/conversation/model/memory.go +++ b/internal/module/conversation/model/memory.go @@ -70,6 +70,31 @@ func (m *MemoryP2P) CreateP2P(_ context.Context, c *entity.Conversation, p2p *en return nil } +func (m *MemoryP2P) Get(_ context.Context, id int64) (*entity.Conversation, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.byID[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *row + return &cp, nil +} + +func (m *MemoryP2P) ListMemberIDs(_ context.Context, conversationID int64) ([]int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + byUser, ok := m.members[conversationID] + if !ok { + return nil, errs.ErrNotFound + } + ids := make([]int64, 0, len(byUser)) + for uid := range byUser { + ids = append(ids, uid) + } + return ids, nil +} + func (m *MemoryP2P) GetMember(_ context.Context, conversationID, userID int64) (*entity.Member, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/module/conversation/model/repo.go b/internal/module/conversation/model/repo.go index 210d8e9..143a5aa 100644 --- a/internal/module/conversation/model/repo.go +++ b/internal/module/conversation/model/repo.go @@ -77,7 +77,7 @@ func (m *Repo) CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entit } return db.Transaction(func(tx *gorm.DB) error { conv := Conversation{ - ID: c.ID, Type: c.Type, LastMsgID: c.LastMsgID, + ID: c.ID, Type: int(c.Type), LastMsgID: c.LastMsgID, LastMsgAt: msTime(c.LastMsgAt), CreatedAt: msTime(c.LastMsgAt), } if err := tx.Create(&conv).Error; err != nil { @@ -126,7 +126,7 @@ func (r *Conversation) entity() *entity.Conversation { return nil } return &entity.Conversation{ - ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(), + ID: r.ID, Type: entity.Type(r.Type), LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(), } } diff --git a/internal/module/deliver/entity/job.go b/internal/module/deliver/entity/job.go index 6fa7763..fb356f4 100644 --- a/internal/module/deliver/entity/job.go +++ b/internal/module/deliver/entity/job.go @@ -13,6 +13,7 @@ type Job struct { NotPush bool `json:"not_push,omitempty"` NotMailbox bool `json:"not_mailbox,omitempty"` Payload []byte `json:"payload,omitempty"` + Preview []byte `json:"preview,omitempty"` raw []byte `json:"-"` } diff --git a/internal/module/deliver/logic/fanout.go b/internal/module/deliver/logic/fanout.go new file mode 100644 index 0000000..2261f96 --- /dev/null +++ b/internal/module/deliver/logic/fanout.go @@ -0,0 +1,86 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/deliver/entity" + inboxlogic "spark-server/internal/module/inbox/logic" + syncentity "spark-server/internal/module/sync/entity" + "spark-server/internal/transport/tcp/protocol" + "spark-server/internal/transport/tcp/session" +) + +type Mailbox interface { + Put(ctx context.Context, userID int64, eventType syncentity.EventType, conversationID int64, payload []byte) error +} + +// InboxFanout updates Redis dialog ZSET then pushes to online sessions. +func InboxFanout(sessions session.Manager, inbox inboxlogic.Store, mailbox Mailbox) Handler { + push := SessionFanout(sessions) + return func(ctx context.Context, job *entity.Job) error { + if err := applyInbox(ctx, inbox, job); err != nil { + return err + } + if err := applyMailbox(ctx, mailbox, job); err != nil { + return err + } + return push(ctx, job) + } +} + +func applyInbox(ctx context.Context, inbox inboxlogic.Store, job *entity.Job) error { + if inbox == nil || job == nil || job.ConversationID == 0 { + return nil + } + if len(job.Preview) > 0 { + if err := inbox.SetPreview(ctx, job.ConversationID, job.Preview); err != nil { + return err + } + } + if job.Cmd != 0 && job.Cmd != protocol.CmdMessage { + return nil + } + score := job.MsgID + for _, uid := range job.ToUserIDs { + if uid == job.FromUserID { + if err := inbox.TouchRead(ctx, uid, job.ConversationID, score); err != nil { + return err + } + continue + } + if err := inbox.TouchUnread(ctx, uid, job.ConversationID, score); err != nil { + return err + } + if err := inbox.IncrUnread(ctx, uid, job.ConversationID, 1); err != nil { + return err + } + } + return nil +} + +func applyMailbox(ctx context.Context, mailbox Mailbox, job *entity.Job) error { + if mailbox == nil || job == nil || job.NotMailbox { + return nil + } + ev := mailboxEventType(job.Cmd) + if ev == 0 { + return nil + } + for _, uid := range job.ToUserIDs { + if err := mailbox.Put(ctx, uid, ev, job.ConversationID, job.Payload); err != nil { + return err + } + } + return nil +} + +func mailboxEventType(cmd uint16) syncentity.EventType { + switch cmd { + case protocol.CmdRecallMessage: + return syncentity.EventRecall + case protocol.CmdDeleteMessage: + return syncentity.EventDelete + default: + return 0 + } +} diff --git a/internal/module/deliver/logic/fanout_test.go b/internal/module/deliver/logic/fanout_test.go new file mode 100644 index 0000000..2263cbd --- /dev/null +++ b/internal/module/deliver/logic/fanout_test.go @@ -0,0 +1,72 @@ +package logic + +import ( + "testing" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/module/deliver/entity" + inboxmodel "spark-server/internal/module/inbox/model" + syncentity "spark-server/internal/module/sync/entity" + syncmodel "spark-server/internal/module/sync/model" + "spark-server/internal/transport/tcp/protocol" + "spark-server/internal/transport/tcp/session" +) + +func TestInboxFanoutMessageIncrementsUnreadAndPushes(t *testing.T) { + inbox := inboxmodel.NewMemoryStore() + mgr := session.NewManager() + peer := session.NewConn("p", "203.0.113.10", 8) + peer.Authenticate(2, 9) + if err := mgr.Bind(peer); err != nil { + t.Fatal(err) + } + job := &entity.Job{ + MsgID: 11, Cmd: protocol.CmdMessage, ConversationID: 5, + FromUserID: 1, ExcludeDevice: 7, ToUserIDs: []int64{1, 2}, + Payload: []byte(`{"code":"0"}`), Preview: []byte(`{"t":1}`), + } + if err := InboxFanout(mgr, inbox, nil)(t.Context(), job); err != nil { + t.Fatal(err) + } + if inbox.UnreadCount(2, 5) != 1 { + t.Fatalf("unread %d", inbox.UnreadCount(2, 5)) + } + pkts := peer.DrainOutbound(2) + if len(pkts) != 1 || pkts[0].Command != protocol.CmdMessage { + t.Fatalf("push %+v", pkts) + } +} + +func TestInboxFanoutRecallSkipsUnread(t *testing.T) { + inbox := inboxmodel.NewMemoryStore() + job := &entity.Job{ + MsgID: 12, Cmd: protocol.CmdRecallMessage, ConversationID: 5, + FromUserID: 1, ToUserIDs: []int64{1, 2}, Preview: []byte(`{"status":2}`), + } + if err := applyInbox(t.Context(), inbox, job); err != nil { + t.Fatal(err) + } + if inbox.UnreadCount(2, 5) != 0 { + t.Fatal("recall must not bump unread") + } +} + +func TestInboxFanoutRecallWritesMailbox(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + inbox := inboxmodel.NewMemoryStore() + box := syncmodel.NewMemoryStore(ids) + job := &entity.Job{ + MsgID: 12, Cmd: protocol.CmdRecallMessage, ConversationID: 5, + FromUserID: 1, ToUserIDs: []int64{1, 2}, Payload: []byte(`{"msg_id":12}`), + } + if err := InboxFanout(nil, inbox, box)(t.Context(), job); err != nil { + t.Fatal(err) + } + items, err := box.ListSince(t.Context(), 2, 0, 0, 10) + if err != nil || len(items) != 1 || items[0].EventType != syncentity.EventRecall { + t.Fatalf("mailbox %+v %v", items, err) + } +} diff --git a/internal/module/favorite/logic/create.go b/internal/module/favorite/logic/create.go index 4921248..2c08c30 100644 --- a/internal/module/favorite/logic/create.go +++ b/internal/module/favorite/logic/create.go @@ -23,7 +23,7 @@ type Item struct { ID int64 `json:"id"` MsgID int64 `json:"msg_id"` ConversationID int64 `json:"conversation_id"` - MessageType int16 `json:"message_type"` + MessageType msgentity.Type `json:"message_type"` Content json.RawMessage `json:"content"` CreatedAt int64 `json:"created_at"` } @@ -112,6 +112,6 @@ func itemOf(fav *entity.Favorite) *Item { } return &Item{ ID: fav.ID, MsgID: fav.MsgID, ConversationID: fav.ConversationID, - MessageType: fav.MessageType, Content: fav.Content, CreatedAt: fav.CreatedAtMS, + MessageType: msgentity.Type(fav.MessageType), Content: fav.Content, CreatedAt: fav.CreatedAtMS, } } diff --git a/internal/module/friend/entity/accept.go b/internal/module/friend/entity/accept.go index a596b38..a5903d4 100644 --- a/internal/module/friend/entity/accept.go +++ b/internal/module/friend/entity/accept.go @@ -2,7 +2,6 @@ package entity // AcceptParams is the transactional write when two users become friends. type AcceptParams struct { - Relation *Relation + Edges []*Relation Requests []int64 - Remarks []Remark } diff --git a/internal/module/friend/entity/block.go b/internal/module/friend/entity/block.go deleted file mode 100644 index 4dcf0fd..0000000 --- a/internal/module/friend/entity/block.go +++ /dev/null @@ -1,10 +0,0 @@ -package entity - -import "time" - -// Block is unidirectional: UserID blocked BlockedID. -type Block struct { - UserID int64 - BlockedID int64 - CreatedAt time.Time -} diff --git a/internal/module/friend/entity/relation.go b/internal/module/friend/entity/relation.go index 41c3bd7..86d3f45 100644 --- a/internal/module/friend/entity/relation.go +++ b/internal/module/friend/entity/relation.go @@ -3,39 +3,56 @@ package entity import "time" const ( - RelationNone = 0 - RelationFriend = 1 - RelationBlock = 2 + RelationNone RelationKind = 0 + RelationFriend RelationKind = 1 + RelationBlock RelationKind = 2 + + StatusNone EdgeStatus = 0 + StatusFriend EdgeStatus = 1 ) +// RelationKind is the viewer-centric JSON `relation` (not the table column). +type RelationKind int + +func (k RelationKind) Valid() bool { + return k == RelationNone || k == RelationFriend || k == RelationBlock +} + +// EdgeStatus is persisted as friend_relations.status. +type EdgeStatus int + +func (s EdgeStatus) Valid() bool { + return s == StatusNone || s == StatusFriend +} + const ( ListDefault = 20 ListMax = 100 ) -// Relation is a bidirectional friendship. Natural key (user_small, user_big). +// Relation is one directed edge: UserID's view of FriendID. type Relation struct { - UserSmall int64 - UserBig int64 + ID int64 + UserID int64 + FriendID int64 + Status EdgeStatus + Remark string + Blocked bool + Pinned bool + Source Source CreatedAt time.Time + UpdatedAt time.Time } -func NewRelation(a, b int64, at time.Time) *Relation { - small, big := OrderUsers(a, b) - return &Relation{UserSmall: small, UserBig: big, CreatedAt: at} +func (r *Relation) Active() bool { + return r != nil && r.Status == StatusFriend } -func (r *Relation) Other(me int64) int64 { - if r == nil { - return 0 +func NewFriendEdge(userID, friendID int64, remark string, source Source, at time.Time) *Relation { + return &Relation{ + UserID: userID, FriendID: friendID, Status: StatusFriend, + Remark: remark, Source: source, CreatedAt: at, UpdatedAt: at, } - if r.UserSmall == me { - return r.UserBig - } - if r.UserBig == me { - return r.UserSmall - } - return 0 } func ClampLimit(limit int) int { @@ -48,8 +65,8 @@ func ClampLimit(limit int) int { return limit } -// ViewStatus is the viewer-centric relation: friend, blocked-by-me, or none. -func ViewStatus(friend, blockedByMe bool) int { +// ViewRelation is the viewer-centric relation: friend, blocked-by-me, or none. +func ViewRelation(friend, blockedByMe bool) RelationKind { if blockedByMe { return RelationBlock } diff --git a/internal/module/friend/entity/remark.go b/internal/module/friend/entity/remark.go deleted file mode 100644 index 04b8226..0000000 --- a/internal/module/friend/entity/remark.go +++ /dev/null @@ -1,11 +0,0 @@ -package entity - -import "time" - -// Remark is viewer-only (UserID's note on FriendID). -type Remark struct { - UserID int64 - FriendID int64 - Text string - UpdatedAt time.Time -} diff --git a/internal/module/friend/entity/report.go b/internal/module/friend/entity/report.go index 5fa7b7b..2375c20 100644 --- a/internal/module/friend/entity/report.go +++ b/internal/module/friend/entity/report.go @@ -3,12 +3,19 @@ package entity import "time" const ( - ReportSpam = 1 - ReportHarassment = 2 - ReportFake = 3 - ReportOther = 4 + ReportSpam ReportReason = 1 + ReportHarassment ReportReason = 2 + ReportFake ReportReason = 3 + ReportOther ReportReason = 4 ) +// ReportReason is persisted as friend_reports.reason. +type ReportReason int + +func (r ReportReason) Valid() bool { + return r >= ReportSpam && r <= ReportOther +} + const ReportDetailMax = 512 // Report is a complaint about TargetID by UserID. @@ -16,11 +23,11 @@ type Report struct { ID int64 UserID int64 TargetID int64 - Reason int + Reason ReportReason Detail string CreatedAt time.Time } func ValidReportReason(r int) bool { - return r >= ReportSpam && r <= ReportOther + return ReportReason(r).Valid() } diff --git a/internal/module/friend/entity/request.go b/internal/module/friend/entity/request.go index 45383e6..37e719e 100644 --- a/internal/module/friend/entity/request.go +++ b/internal/module/friend/entity/request.go @@ -3,17 +3,32 @@ package entity import "time" const ( - RequestPending = 1 - RequestAccepted = 2 - RequestRejected = 3 + RequestPending RequestStatus = 1 + RequestAccepted RequestStatus = 2 + RequestRejected RequestStatus = 3 ) +// RequestStatus is persisted as friend_requests.status and returned as apply `status`. +type RequestStatus int + +func (s RequestStatus) Valid() bool { + return s == RequestPending || s == RequestAccepted || s == RequestRejected +} + const ( - SourceQRCode = 1 - SourceEmail = 2 - SourceSparkID = 3 + SourceUnknown Source = 0 + SourceQRCode Source = 1 + SourceEmail Source = 2 + SourceSparkID Source = 3 ) +// Source is friend add origin on the request and the directed edge. +type Source int + +func (s Source) Valid() bool { + return s == SourceUnknown || s == SourceQRCode || s == SourceEmail || s == SourceSparkID +} + const ( ReasonMax = 128 RemarkMax = 64 @@ -24,14 +39,14 @@ type Request struct { ID int64 FromUser int64 ToUser int64 - Source int + Source Source Reason string Remark string - Status int + Status RequestStatus CreatedAt time.Time UpdatedAt time.Time } func ValidSource(src int) bool { - return src == SourceQRCode || src == SourceEmail || src == SourceSparkID + return Source(src).Valid() && Source(src) != SourceUnknown } diff --git a/internal/module/friend/logic/accept.go b/internal/module/friend/logic/accept.go index b10e77b..eab1720 100644 --- a/internal/module/friend/logic/accept.go +++ b/internal/module/friend/logic/accept.go @@ -51,19 +51,16 @@ func (s *Service) Accept(ctx context.Context, userID int64, in AcceptInput) (*Ac func (s *Service) becomeFriends(ctx context.Context, req *entity.Request, accepter int64, accepterRemark string) error { now := time.Now() - rel := entity.NewRelation(req.FromUser, req.ToUser, now) + fromRemark := strings.TrimSpace(req.Remark) + edges := []*entity.Relation{ + entity.NewFriendEdge(req.FromUser, req.ToUser, fromRemark, req.Source, now), + entity.NewFriendEdge(accepter, req.FromUser, accepterRemark, req.Source, now), + } ids := []int64{req.ID} if reverse, err := s.repo.GetRequestByPair(ctx, accepter, req.FromUser); err == nil && reverse.ID != req.ID { ids = append(ids, reverse.ID) } - remarks := make([]entity.Remark, 0, 2) - if strings.TrimSpace(req.Remark) != "" { - remarks = append(remarks, entity.Remark{UserID: req.FromUser, FriendID: req.ToUser, Text: req.Remark, UpdatedAt: now}) - } - if accepterRemark != "" { - remarks = append(remarks, entity.Remark{UserID: accepter, FriendID: req.FromUser, Text: accepterRemark, UpdatedAt: now}) - } - if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Relation: rel, Requests: ids, Remarks: remarks}); err != nil { + if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Edges: edges, Requests: ids}); err != nil { return err } if s.greeter != nil { diff --git a/internal/module/friend/logic/apply.go b/internal/module/friend/logic/apply.go index b1adcc7..851b204 100644 --- a/internal/module/friend/logic/apply.go +++ b/internal/module/friend/logic/apply.go @@ -26,9 +26,9 @@ type ApplyInput struct { // ApplyResult is returned after applying or auto-accepting a reverse request. type ApplyResult struct { - Status string `json:"status"` - RequestID int64 `json:"request_id,omitempty"` - UID int64 `json:"uid"` + Status entity.RequestStatus `json:"status"` + RequestID int64 `json:"request_id,omitempty"` + UID int64 `json:"uid"` } func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (*ApplyResult, error) { @@ -52,7 +52,7 @@ func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) ( if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil { return nil, err } - return &ApplyResult{Status: "accepted", UID: target.ID}, nil + return &ApplyResult{Status: entity.RequestAccepted, UID: target.ID}, nil } else if err != nil && !errors.Is(err, errs.ErrNotFound) { return nil, err } @@ -60,10 +60,10 @@ func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) ( if err != nil { return nil, err } - return &ApplyResult{Status: "pending", RequestID: req.ID, UID: target.ID}, nil + return &ApplyResult{Status: entity.RequestPending, RequestID: req.ID, UID: target.ID}, nil } -func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, int, error) { +func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, entity.Source, error) { lang := reqctx.Lang(ctx) n := 0 if in.UID > 0 { @@ -101,7 +101,7 @@ func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentit } } -func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source int) (*userentity.User, int, error) { +func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source entity.Source) (*userentity.User, entity.Source, error) { if err != nil { if errors.Is(err, errs.ErrNotFound) { return nil, 0, errs.ErrNotFound @@ -144,15 +144,17 @@ func (s *Service) canApply(ctx context.Context, from, to int64) error { if blockedBy { return errs.ErrNotFound } - if _, err := s.repo.GetRelation(ctx, from, to); err == nil { - return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil)) - } else if !errors.Is(err, errs.ErrNotFound) { + ok, err := s.AreFriends(ctx, from, to) + if err != nil { return err } + if ok { + return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil)) + } return nil } -func (s *Service) upsertPending(ctx context.Context, from, to int64, source int, in *ApplyInput) (*entity.Request, error) { +func (s *Service) upsertPending(ctx context.Context, from, to int64, source entity.Source, in *ApplyInput) (*entity.Request, error) { now := time.Now() req := &entity.Request{ FromUser: from, ToUser: to, Source: source, diff --git a/internal/module/friend/logic/block.go b/internal/module/friend/logic/block.go index 8044de1..0a107b3 100644 --- a/internal/module/friend/logic/block.go +++ b/internal/module/friend/logic/block.go @@ -3,38 +3,36 @@ package logic import ( "context" "errors" - "time" - "spark-server/internal/module/friend/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" ) func (s *Service) Block(ctx context.Context, userID, targetID int64) error { - if !s.ready() || userID <= 0 { - return errs.ErrUnauthorized - } - if targetID <= 0 { - return errs.ErrInvalidArgument - } - if entity.SameUser(userID, targetID) { - return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil)) - } - if _, err := s.activeUser(ctx, targetID); err != nil { - return err - } - return s.repo.CreateBlock(ctx, &entity.Block{UserID: userID, BlockedID: targetID, CreatedAt: time.Now()}) + return s.setBlocked(ctx, userID, targetID, true) } func (s *Service) Unblock(ctx context.Context, userID, targetID int64) error { + return s.setBlocked(ctx, userID, targetID, false) +} + +func (s *Service) setBlocked(ctx context.Context, userID, targetID int64, blocked bool) error { if !s.ready() || userID <= 0 { return errs.ErrUnauthorized } if targetID <= 0 { return errs.ErrInvalidArgument } - return s.repo.DeleteBlock(ctx, userID, targetID) + if userID == targetID { + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil)) + } + if blocked { + if _, err := s.activeUser(ctx, targetID); err != nil { + return err + } + } + return s.repo.SetBlocked(ctx, userID, targetID, blocked) } func (s *Service) Delete(ctx context.Context, userID, friendID int64) error { diff --git a/internal/module/friend/logic/cansend.go b/internal/module/friend/logic/cansend.go index 9fc7ad9..99be587 100644 --- a/internal/module/friend/logic/cansend.go +++ b/internal/module/friend/logic/cansend.go @@ -2,7 +2,6 @@ package logic import ( "context" - "errors" "spark-server/internal/pkg/errs" ) @@ -14,12 +13,13 @@ func (s *Service) CanSend(ctx context.Context, fromUserID, toUserID int64) error if fromUserID == toUserID { return errs.ErrForbidden } - if _, err := s.repo.GetRelation(ctx, fromUserID, toUserID); err != nil { - if errors.Is(err, errs.ErrNotFound) { - return errs.ErrForbidden - } + ok, err := s.AreFriends(ctx, fromUserID, toUserID) + if err != nil { return err } + if !ok { + return errs.ErrForbidden + } blocked, err := s.repo.BlockedEither(ctx, fromUserID, toUserID) if err != nil { return err diff --git a/internal/module/friend/logic/friend_test.go b/internal/module/friend/logic/friend_test.go index 09ca6aa..c4fb8af 100644 --- a/internal/module/friend/logic/friend_test.go +++ b/internal/module/friend/logic/friend_test.go @@ -60,7 +60,7 @@ func TestApplyByEmailSparkIDQRAndDedupe(t *testing.T) { ctx := t.Context() a, b := twoUsers(t, users) first, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Reason: "hi", Remark: "work"}) - if err != nil || first.Status != "pending" || first.RequestID == 0 { + if err != nil || first.Status != entity.RequestPending || first.RequestID == 0 { t.Fatalf("email apply %+v %v", first, err) } second, err := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID, Reason: "again"}) @@ -119,7 +119,7 @@ func TestMutualApplyAutoAccept(t *testing.T) { t.Fatal(err) } out, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com", Remark: "b-note"}) - if err != nil || out.Status != "accepted" { + if err != nil || out.Status != entity.RequestAccepted { t.Fatalf("%+v %v", out, err) } if g.n != 1 { @@ -151,6 +151,14 @@ func TestNoUnidirectionalDeleteAndBlock(t *testing.T) { if err := svc.Block(ctx, a.ID, b.ID); err != nil { t.Fatal(err) } + ok, err := svc.AreFriends(ctx, a.ID, b.ID) + if err != nil || !ok { + t.Fatalf("block keeps friendship %v %v", ok, err) + } + view, err := svc.Get(ctx, a.ID, b.ID) + if err != nil || !view.Blocked || view.Relation != entity.RelationBlock { + t.Fatalf("blocked view %+v %v", view, err) + } if err := svc.CanSend(ctx, a.ID, b.ID); !errors.Is(err, errs.ErrForbidden) { t.Fatalf("blocked send %v", err) } @@ -162,6 +170,39 @@ func TestNoUnidirectionalDeleteAndBlock(t *testing.T) { } } +func TestPinOrdersList(t *testing.T) { + svc, users, _ := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + c, err := users.AuthenticateEmail(ctx, "test03@spark.com", "1", &userentity.LoginDevice{DeviceID: 3, DeviceType: userentity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + for _, peer := range []int64{b.ID, c.ID} { + applied, err := svc.Apply(ctx, a.ID, &ApplyInput{UID: peer}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Accept(ctx, peer, AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + } + if err := svc.Pin(ctx, a.ID, c.ID); err != nil { + t.Fatal(err) + } + list, err := svc.List(ctx, a.ID, 20) + if err != nil || len(list.Items) != 2 || list.Items[0].UID != c.ID || !list.Items[0].Pinned { + t.Fatalf("pinned first %+v %v", list, err) + } + if err := svc.Unpin(ctx, a.ID, c.ID); err != nil { + t.Fatal(err) + } + list, err = svc.List(ctx, a.ID, 20) + if err != nil || list.Items[0].Pinned { + t.Fatalf("unpinned %+v %v", list, err) + } +} + func TestRemarkReportLookup(t *testing.T) { svc, users, _ := testFriends(t) ctx := t.Context() diff --git a/internal/module/friend/logic/friends.go b/internal/module/friend/logic/friends.go index d116dc3..f49c50d 100644 --- a/internal/module/friend/logic/friends.go +++ b/internal/module/friend/logic/friends.go @@ -11,12 +11,22 @@ func (s *Service) AreFriends(ctx context.Context, a, b int64) (bool, error) { if !s.ready() || a <= 0 || b <= 0 || a == b { return false, nil } - _, err := s.repo.GetRelation(ctx, a, b) - if err == nil { - return true, nil + ab, err := s.repo.GetRelation(ctx, a, b) + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return false, nil + } + return false, err } - if errors.Is(err, errs.ErrNotFound) { + if !ab.Active() { return false, nil } - return false, err + ba, err := s.repo.GetRelation(ctx, b, a) + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return false, nil + } + return false, err + } + return ba.Active(), nil } diff --git a/internal/module/friend/logic/greeting.go b/internal/module/friend/logic/greeting.go index 105b52d..a89a36e 100644 --- a/internal/module/friend/logic/greeting.go +++ b/internal/module/friend/logic/greeting.go @@ -55,6 +55,7 @@ func (g *SystemGreeter) BecameFriends(ctx context.Context, userA, userB int64) e SenderID: 0, ClientMsgID: fmt.Sprintf("sys:friend:%d", msgID), Type: msgentity.TypeSystem, + Kind: msgentity.KindPrivate, Content: raw, Status: msgentity.StatusNormal, CreatedAtMS: now.UnixMilli(), @@ -81,7 +82,7 @@ type pushBody struct { MsgID int64 `json:"msg_id"` ConversationID int64 `json:"conversation_id"` SenderID int64 `json:"sender_id"` - MessageType int16 `json:"message_type"` + MessageType msgentity.Type `json:"message_type"` Content json.RawMessage `json:"content"` CreatedAt int64 `json:"created_at"` } @@ -89,6 +90,6 @@ type pushBody struct { func pushBodyOf(m *msgentity.Message) pushBody { return pushBody{ MsgID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, - MessageType: int16(m.Type), Content: m.Content, CreatedAt: m.CreatedAtMS, + MessageType: m.Type, Content: m.Content, CreatedAt: m.CreatedAtMS, } } diff --git a/internal/module/friend/logic/lookup.go b/internal/module/friend/logic/lookup.go index 248a810..a9ff190 100644 --- a/internal/module/friend/logic/lookup.go +++ b/internal/module/friend/logic/lookup.go @@ -33,31 +33,25 @@ func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*Fr if blockedBy { return nil, errs.ErrNotFound } - friend := false - if _, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil { - friend = true - } else if !errors.Is(err, errs.ErrNotFound) { + rel, err := s.repo.GetRelation(ctx, userID, target.ID) + if err != nil && !errors.Is(err, errs.ErrNotFound) { return nil, err } - blockedMe, err := s.repo.IsBlocked(ctx, userID, target.ID) + if errors.Is(err, errs.ErrNotFound) { + rel = nil + } + friend, err := s.AreFriends(ctx, userID, target.ID) if err != nil { return nil, err } - remark := "" - if friend { - remark, err = s.repo.GetRemark(ctx, userID, target.ID) - if err != nil { - return nil, err - } - } - view := &FriendView{ - UserCard: cardOf(target), - Remark: remark, - Relation: entity.ViewStatus(friend, blockedMe), - } - if friend { - if rel, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil { - view.CreatedAt = entity.UnixMS(rel.CreatedAt) + blockedMe := rel != nil && rel.Blocked + view := viewOf(target, rel) + view.Relation = entity.ViewRelation(friend, blockedMe) + if !friend { + view.CreatedAt = 0 + view.Pinned = false + if rel == nil { + view.Remark = "" } } return view, nil diff --git a/internal/module/friend/logic/pin.go b/internal/module/friend/logic/pin.go new file mode 100644 index 0000000..2fdefee --- /dev/null +++ b/internal/module/friend/logic/pin.go @@ -0,0 +1,25 @@ +package logic + +import ( + "context" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) Pin(ctx context.Context, userID, friendID int64) error { + return s.setPinned(ctx, userID, friendID, true) +} + +func (s *Service) Unpin(ctx context.Context, userID, friendID int64) error { + return s.setPinned(ctx, userID, friendID, false) +} + +func (s *Service) setPinned(ctx context.Context, userID, friendID int64, pinned bool) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if err := s.requireFriend(ctx, userID, friendID); err != nil { + return err + } + return s.repo.SetPinned(ctx, userID, friendID, pinned) +} diff --git a/internal/module/friend/logic/profile.go b/internal/module/friend/logic/profile.go index b0a421a..1474ee5 100644 --- a/internal/module/friend/logic/profile.go +++ b/internal/module/friend/logic/profile.go @@ -2,7 +2,6 @@ package logic import ( "context" - "errors" "spark-server/internal/module/friend/entity" userentity "spark-server/internal/module/user/entity" @@ -10,18 +9,20 @@ import ( ) type UserCard struct { - UID int64 `json:"uid"` - SparkID string `json:"spark_id"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - Gender int `json:"gender"` + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Gender userentity.Gender `json:"gender"` } type FriendView struct { UserCard - Remark string `json:"remark"` - Relation int `json:"relation"` - CreatedAt int64 `json:"created_at,omitempty"` + Remark string `json:"remark"` + Relation entity.RelationKind `json:"relation"` + Blocked bool `json:"blocked"` + Pinned bool `json:"pinned"` + CreatedAt int64 `json:"created_at,omitempty"` } type FriendList struct { @@ -38,6 +39,21 @@ func cardOf(u *userentity.User) UserCard { } } +func viewOf(u *userentity.User, rel *entity.Relation) *FriendView { + v := &FriendView{UserCard: cardOf(u), Relation: entity.RelationNone} + if rel == nil { + return v + } + v.Remark = rel.Remark + v.Blocked = rel.Blocked + v.Pinned = rel.Pinned + v.Relation = entity.ViewRelation(rel.Active(), rel.Blocked) + if rel.Active() { + v.CreatedAt = entity.UnixMS(rel.CreatedAt) + } + return v +} + func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) { if !s.ready() || userID <= 0 { return nil, errs.ErrUnauthorized @@ -46,29 +62,13 @@ func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendLis if err != nil { return nil, err } - ids := make([]int64, 0, len(rels)) - for _, rel := range rels { - if other := rel.Other(userID); other > 0 { - ids = append(ids, other) - } - } - notes, err := s.repo.ListRemarks(ctx, userID, ids) - if err != nil { - return nil, err - } items := make([]*FriendView, 0, len(rels)) for _, rel := range rels { - other := rel.Other(userID) - u, err := s.users.Get(ctx, other) + u, err := s.users.Get(ctx, rel.FriendID) if err != nil { continue } - items = append(items, &FriendView{ - UserCard: cardOf(u), - Remark: notes[other], - Relation: entity.RelationFriend, - CreatedAt: entity.UnixMS(rel.CreatedAt), - }) + items = append(items, viewOf(u, rel)) } return &FriendList{Items: items}, nil } @@ -82,23 +82,14 @@ func (s *Service) Get(ctx context.Context, userID, friendID int64) (*FriendView, } rel, err := s.repo.GetRelation(ctx, userID, friendID) if err != nil { - if errors.Is(err, errs.ErrNotFound) { - return nil, errs.ErrNotFound - } return nil, err } + if !rel.Active() { + return nil, errs.ErrNotFound + } u, err := s.activeUser(ctx, friendID) if err != nil { return nil, err } - remark, err := s.repo.GetRemark(ctx, userID, friendID) - if err != nil { - return nil, err - } - return &FriendView{ - UserCard: cardOf(u), - Remark: remark, - Relation: entity.RelationFriend, - CreatedAt: entity.UnixMS(rel.CreatedAt), - }, nil + return viewOf(u, rel), nil } diff --git a/internal/module/friend/logic/remark.go b/internal/module/friend/logic/remark.go index f559798..e1b9095 100644 --- a/internal/module/friend/logic/remark.go +++ b/internal/module/friend/logic/remark.go @@ -2,7 +2,6 @@ package logic import ( "context" - "errors" "strings" "spark-server/internal/pkg/errs" @@ -32,12 +31,12 @@ func (s *Service) requireFriend(ctx context.Context, userID, friendID int64) err if friendID <= 0 { return errs.ErrInvalidArgument } - _, err := s.repo.GetRelation(ctx, userID, friendID) - if err == nil { - return nil + ok, err := s.AreFriends(ctx, userID, friendID) + if err != nil { + return err } - if errors.Is(err, errs.ErrNotFound) { + if !ok { return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil)) } - return err + return nil } diff --git a/internal/module/friend/logic/report.go b/internal/module/friend/logic/report.go index a6fdea5..0f88b20 100644 --- a/internal/module/friend/logic/report.go +++ b/internal/module/friend/logic/report.go @@ -13,8 +13,8 @@ import ( ) type ReportInput struct { - Reason int `json:"reason" validate:"required,oneof=1 2 3 4" label:"field.report_reason" errmsg:"required:validate.friend.report_reason_required;oneof:validate.friend.report_reason_invalid"` - Detail string `json:"detail" validate:"omitempty,max=512" label:"field.report_detail" errmsg:"max:validate.friend.report_detail_invalid"` + Reason entity.ReportReason `json:"reason" validate:"required,oneof=1 2 3 4" label:"field.report_reason" errmsg:"required:validate.friend.report_reason_required;oneof:validate.friend.report_reason_invalid"` + Detail string `json:"detail" validate:"omitempty,max=512" label:"field.report_detail" errmsg:"max:validate.friend.report_detail_invalid"` } func (s *Service) Report(ctx context.Context, userID, targetID int64, in ReportInput) error { diff --git a/internal/module/friend/logic/requests.go b/internal/module/friend/logic/requests.go index 4c60c44..5e021ff 100644 --- a/internal/module/friend/logic/requests.go +++ b/internal/module/friend/logic/requests.go @@ -8,12 +8,12 @@ import ( ) type RequestView struct { - ID int64 `json:"id"` - From UserCard `json:"from"` - Reason string `json:"reason"` - Source int `json:"source"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID int64 `json:"id"` + From UserCard `json:"from"` + Reason string `json:"reason"` + Source entity.Source `json:"source"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type RequestList struct { diff --git a/internal/module/friend/logic/service.go b/internal/module/friend/logic/service.go index a2c962c..db59c4b 100644 --- a/internal/module/friend/logic/service.go +++ b/internal/module/friend/logic/service.go @@ -10,19 +10,17 @@ import ( // Repository is friend persistence. Model implements it; do not split this by table. type Repository interface { - GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error) + GetRelation(ctx context.Context, userID, friendID int64) (*entity.Relation, error) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) GetRequest(ctx context.Context, id int64) (*entity.Request, error) GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error) UpsertRequest(ctx context.Context, req *entity.Request) error - ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) - GetRemark(ctx context.Context, userID, friendID int64) (string, error) - ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error) + ListIncoming(ctx context.Context, toUserID int64, status entity.RequestStatus, limit int) ([]*entity.Request, error) SetRemark(ctx context.Context, userID, friendID int64, remark string) error + SetPinned(ctx context.Context, userID, friendID int64, pinned bool) error + SetBlocked(ctx context.Context, userID, friendID int64, blocked bool) error IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) BlockedEither(ctx context.Context, a, b int64) (bool, error) - CreateBlock(ctx context.Context, b *entity.Block) error - DeleteBlock(ctx context.Context, userID, blockedID int64) error CreateReport(ctx context.Context, r *entity.Report) error AcceptFriend(ctx context.Context, in entity.AcceptParams) error DeleteFriend(ctx context.Context, a, b int64) error diff --git a/internal/module/friend/model/accept.go b/internal/module/friend/model/accept.go index 782e7c5..4251b41 100644 --- a/internal/module/friend/model/accept.go +++ b/internal/module/friend/model/accept.go @@ -11,28 +11,47 @@ import ( ) func (m *Repo) AcceptFriend(ctx context.Context, in entity.AcceptParams) error { - if in.Relation == nil { + if len(in.Edges) == 0 { return errs.ErrInvalidArgument } db, err := m.session(ctx) if err != nil { return err } - now := in.Relation.CreatedAt - if now.IsZero() { - now = time.Now() - in.Relation.CreatedAt = now + now := time.Now() + for _, e := range in.Edges { + if e == nil || e.UserID <= 0 || e.FriendID <= 0 { + return errs.ErrInvalidArgument + } + if e.CreatedAt.IsZero() { + e.CreatedAt = now + } + if e.UpdatedAt.IsZero() { + e.UpdatedAt = e.CreatedAt + } + } + if !in.Edges[0].CreatedAt.IsZero() { + now = in.Edges[0].CreatedAt } return db.Transaction(func(tx *gorm.DB) error { - row := FriendRelation{UserSmall: in.Relation.UserSmall, UserBig: in.Relation.UserBig, CreatedAt: now} - if err := tx.Where("user_small = ? AND user_big = ?", row.UserSmall, row.UserBig). - Attrs(row).FirstOrCreate(&row).Error; err != nil { - return mapDBErr(err) + for _, e := range in.Edges { + row := rowFromEdge(e) + assigns := map[string]any{ + "status": entity.StatusFriend, + "updated_at": e.UpdatedAt, + } + if e.Remark != "" { + assigns["remark"] = e.Remark + } + if e.Source != 0 { + assigns["source"] = e.Source + } + if err := tx.Where("user_id = ? AND friend_id = ?", row.UserID, row.FriendID). + Attrs(row).Assign(assigns).FirstOrCreate(&row).Error; err != nil { + return mapDBErr(err) + } } - if err := markRequestsAccepted(tx, in.Requests, now); err != nil { - return err - } - return upsertRemarks(tx, in.Remarks) + return markRequestsAccepted(tx, in.Requests, now) }) } @@ -41,15 +60,13 @@ func (m *Repo) DeleteFriend(ctx context.Context, a, b int64) error { if err != nil { return err } - return db.Transaction(func(tx *gorm.DB) error { - small, big := entity.OrderUsers(a, b) - res := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{}) - if res.Error != nil { - return mapDBErr(res.Error) - } - if res.RowsAffected == 0 { - return errs.ErrNotFound - } - return deleteRemarksBetween(tx, a, b) - }) + res := db.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", a, b, b, a). + Delete(&FriendRelation{}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil } diff --git a/internal/module/friend/model/friend_relation.go b/internal/module/friend/model/friend_relation.go index 91c7823..8292a60 100644 --- a/internal/module/friend/model/friend_relation.go +++ b/internal/module/friend/model/friend_relation.go @@ -2,16 +2,27 @@ package model import ( "context" + "errors" "time" + "gorm.io/gorm" + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" ) -// FriendRelation maps table friend_relations. +// FriendRelation maps table friend_relations (one directed edge per row). type FriendRelation struct { - UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"` - UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"` - CreatedAt time.Time `gorm:"column:created_at;comment:成为好友时间"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"` + UserID int64 `gorm:"column:user_id;comment:关系所属用户"` + FriendID int64 `gorm:"column:friend_id;comment:对方 user_id"` + Status int `gorm:"column:status;comment:1=好友 0=非好友(仅用于单向拉黑占位)"` + Remark string `gorm:"column:remark;comment:备注,仅自己可见"` + Blocked bool `gorm:"column:is_blocked;comment:是否拉黑对方"` + Pinned bool `gorm:"column:is_pinned;comment:会话消息置顶"` + Source int `gorm:"column:source;comment:添加来源:0=未知 1=二维码 2=邮箱 3=spark_id"` + CreatedAt time.Time `gorm:"column:created_at;comment:成为好友或建边时间"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:最近更新时间"` } func (FriendRelation) TableName() string { return "friend_relations" } @@ -20,17 +31,28 @@ func (r *FriendRelation) entity() *entity.Relation { if r == nil { return nil } - return &entity.Relation{UserSmall: r.UserSmall, UserBig: r.UserBig, CreatedAt: r.CreatedAt} + return &entity.Relation{ + ID: r.ID, UserID: r.UserID, FriendID: r.FriendID, Status: entity.EdgeStatus(r.Status), + Remark: r.Remark, Blocked: r.Blocked, Pinned: r.Pinned, Source: entity.Source(r.Source), + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } } -func (m *Repo) GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error) { +func rowFromEdge(e *entity.Relation) FriendRelation { + return FriendRelation{ + UserID: e.UserID, FriendID: e.FriendID, Status: int(e.Status), + Remark: e.Remark, Blocked: e.Blocked, Pinned: e.Pinned, Source: int(e.Source), + CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, + } +} + +func (m *Repo) GetRelation(ctx context.Context, userID, friendID int64) (*entity.Relation, error) { db, err := m.session(ctx) if err != nil { return nil, err } - small, big := entity.OrderUsers(a, b) var row FriendRelation - if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil { + if err := db.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error; err != nil { return nil, mapDBErr(err) } return row.entity(), nil @@ -43,8 +65,8 @@ func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*ent } limit = entity.ClampLimit(limit) var rows []FriendRelation - err = db.Where("user_small = ? OR user_big = ?", userID, userID). - Order("created_at DESC").Limit(limit).Find(&rows).Error + err = db.Where("user_id = ? AND status = ?", userID, entity.StatusFriend). + Order("is_pinned DESC, created_at DESC").Limit(limit).Find(&rows).Error if err != nil { return nil, mapDBErr(err) } @@ -54,3 +76,88 @@ func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*ent } return out, nil } + +func (m *Repo) SetRemark(ctx context.Context, userID, friendID int64, remark string) error { + return m.updateEdge(ctx, userID, friendID, map[string]any{"remark": remark, "updated_at": time.Now()}) +} + +func (m *Repo) SetPinned(ctx context.Context, userID, friendID int64, pinned bool) error { + return m.updateEdge(ctx, userID, friendID, map[string]any{"is_pinned": pinned, "updated_at": time.Now()}) +} + +func (m *Repo) updateEdge(ctx context.Context, userID, friendID int64, values map[string]any) error { + if userID <= 0 || friendID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&FriendRelation{}). + Where("user_id = ? AND friend_id = ? AND status = ?", userID, friendID, entity.StatusFriend). + Updates(values) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) { + rel, err := m.GetRelation(ctx, userID, targetID) + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return false, nil + } + return false, err + } + return rel.Blocked, nil +} + +func (m *Repo) BlockedEither(ctx context.Context, a, b int64) (bool, error) { + me, err := m.IsBlocked(ctx, a, b) + if err != nil || me { + return me, err + } + return m.IsBlocked(ctx, b, a) +} + +func (m *Repo) SetBlocked(ctx context.Context, userID, friendID int64, blocked bool) error { + if userID <= 0 || friendID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + var row FriendRelation + err := tx.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return mapDBErr(err) + } + if errors.Is(err, gorm.ErrRecordNotFound) { + if !blocked { + return nil + } + row = FriendRelation{ + UserID: userID, FriendID: friendID, Status: int(entity.StatusNone), + Blocked: true, CreatedAt: now, UpdatedAt: now, + } + if err := tx.Create(&row).Error; err != nil { + return mapDBErr(err) + } + } else { + if err := tx.Model(&row).Updates(map[string]any{"is_blocked": blocked, "updated_at": now}).Error; err != nil { + return mapDBErr(err) + } + } + if blocked { + return rejectPairRequests(tx, userID, friendID, now) + } + return nil + }) +} diff --git a/internal/module/friend/model/friend_remark.go b/internal/module/friend/model/friend_remark.go deleted file mode 100644 index 93c9b4c..0000000 --- a/internal/module/friend/model/friend_remark.go +++ /dev/null @@ -1,89 +0,0 @@ -package model - -import ( - "context" - "errors" - "time" - - "gorm.io/gorm" - - "spark-server/internal/module/friend/entity" - "spark-server/internal/pkg/errs" -) - -// FriendRemark maps table friend_remarks. -type FriendRemark struct { - UserID int64 `gorm:"column:user_id;primaryKey;comment:备注所属用户"` - FriendID int64 `gorm:"column:friend_id;primaryKey;comment:被备注的用户"` - Remark string `gorm:"column:remark;comment:备注名,仅对备注者可见"` - UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"` -} - -func (FriendRemark) TableName() string { return "friend_remarks" } - -func (m *Repo) GetRemark(ctx context.Context, userID, friendID int64) (string, error) { - db, err := m.session(ctx) - if err != nil { - return "", err - } - var row FriendRemark - if err := db.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error; err != nil { - mapped := mapDBErr(err) - if errors.Is(mapped, errs.ErrNotFound) { - return "", nil - } - return "", mapped - } - return row.Remark, nil -} - -func (m *Repo) ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error) { - out := make(map[int64]string, len(friendIDs)) - if len(friendIDs) == 0 { - return out, nil - } - db, err := m.session(ctx) - if err != nil { - return nil, err - } - var rows []FriendRemark - if err := db.Where("user_id = ? AND friend_id IN ?", userID, friendIDs).Find(&rows).Error; err != nil { - return nil, mapDBErr(err) - } - for i := range rows { - out[rows[i].FriendID] = rows[i].Remark - } - return out, nil -} - -func (m *Repo) SetRemark(ctx context.Context, userID, friendID int64, remark string) error { - if userID <= 0 || friendID <= 0 { - return errs.ErrInvalidArgument - } - db, err := m.session(ctx) - if err != nil { - return err - } - row := FriendRemark{UserID: userID, FriendID: friendID, Remark: remark, UpdatedAt: time.Now()} - return mapDBErr(db.Save(&row).Error) -} - -func upsertRemarks(tx *gorm.DB, remarks []entity.Remark) error { - for i := range remarks { - r := remarks[i] - if r.UserID <= 0 || r.FriendID <= 0 || r.Text == "" { - continue - } - row := FriendRemark{UserID: r.UserID, FriendID: r.FriendID, Remark: r.Text, UpdatedAt: r.UpdatedAt} - if err := tx.Save(&row).Error; err != nil { - return mapDBErr(err) - } - } - return nil -} - -func deleteRemarksBetween(tx *gorm.DB, a, b int64) error { - err := tx.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", a, b, b, a). - Delete(&FriendRemark{}).Error - return mapDBErr(err) -} diff --git a/internal/module/friend/model/friend_report.go b/internal/module/friend/model/friend_report.go index f45b267..4d258ac 100644 --- a/internal/module/friend/model/friend_report.go +++ b/internal/module/friend/model/friend_report.go @@ -30,7 +30,7 @@ func (m *Repo) CreateReport(ctx context.Context, r *entity.Report) error { } row := FriendReport{ ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, - Reason: r.Reason, Detail: r.Detail, CreatedAt: r.CreatedAt, + Reason: int(r.Reason), Detail: r.Detail, CreatedAt: r.CreatedAt, } return mapDBErr(db.Create(&row).Error) } diff --git a/internal/module/friend/model/friend_request.go b/internal/module/friend/model/friend_request.go index 7acae3d..eeee5d3 100644 --- a/internal/module/friend/model/friend_request.go +++ b/internal/module/friend/model/friend_request.go @@ -18,7 +18,7 @@ type FriendRequest struct { ToUser int64 `gorm:"column:to_user;comment:被申请人 user_id"` Source int `gorm:"column:source;comment:来源:1=二维码 2=邮箱 3=spark_id"` Reason string `gorm:"column:reason;comment:申请理由"` - Remark string `gorm:"column:remark;comment:申请人对对方的备注,同意后写入 friend_remarks"` + Remark string `gorm:"column:remark;comment:申请人对对方的备注,同意后写入自己的好友边"` Status int `gorm:"column:status;comment:1=待处理 2=已同意 3=已拒绝"` CreatedAt time.Time `gorm:"column:created_at;comment:首次申请时间"` UpdatedAt time.Time `gorm:"column:updated_at;comment:最近一次申请或处理时间"` @@ -31,16 +31,16 @@ func (r *FriendRequest) entity() *entity.Request { return nil } return &entity.Request{ - ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source, - Reason: r.Reason, Remark: r.Remark, Status: r.Status, + ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: entity.Source(r.Source), + Reason: r.Reason, Remark: r.Remark, Status: entity.RequestStatus(r.Status), CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } } func requestFromEntity(r *entity.Request) FriendRequest { return FriendRequest{ - ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source, - Reason: r.Reason, Remark: r.Remark, Status: r.Status, + ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: int(r.Source), + Reason: r.Reason, Remark: r.Remark, Status: int(r.Status), CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } } @@ -93,7 +93,7 @@ func (m *Repo) UpsertRequest(ctx context.Context, req *entity.Request) error { return nil } -func (m *Repo) ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) { +func (m *Repo) ListIncoming(ctx context.Context, toUserID int64, status entity.RequestStatus, limit int) ([]*entity.Request, error) { db, err := m.session(ctx) if err != nil { return nil, err diff --git a/internal/module/friend/model/memory.go b/internal/module/friend/model/memory.go index 926992b..4ab8dd8 100644 --- a/internal/module/friend/model/memory.go +++ b/internal/module/friend/model/memory.go @@ -10,9 +10,9 @@ import ( "spark-server/internal/pkg/errs" ) -type pairKey struct { - Small int64 - Big int64 +type edgeKey struct { + UserID int64 + FriendID int64 } type requestKey struct { @@ -20,41 +20,27 @@ type requestKey struct { To int64 } -type remarkKey struct { - UserID int64 - FriendID int64 -} - -type blockKey struct { - UserID int64 - BlockedID int64 -} - // MemoryRepo is an in-process friend store for tests and empty-DSN bring-up. type MemoryRepo struct { mu sync.Mutex - relations map[pairKey]*entity.Relation + relations map[edgeKey]*entity.Relation requests map[int64]*entity.Request byPair map[requestKey]int64 - remarks map[remarkKey]string - blocks map[blockKey]time.Time reports []*entity.Report } func NewMemoryRepo() *MemoryRepo { return &MemoryRepo{ - relations: make(map[pairKey]*entity.Relation), + relations: make(map[edgeKey]*entity.Relation), requests: make(map[int64]*entity.Request), byPair: make(map[requestKey]int64), - remarks: make(map[remarkKey]string), - blocks: make(map[blockKey]time.Time), } } -func (m *MemoryRepo) GetRelation(_ context.Context, a, b int64) (*entity.Relation, error) { +func (m *MemoryRepo) GetRelation(_ context.Context, userID, friendID int64) (*entity.Relation, error) { m.mu.Lock() defer m.mu.Unlock() - rel, ok := m.relations[pairOf(a, b)] + rel, ok := m.relations[edgeKey{UserID: userID, FriendID: friendID}] if !ok { return nil, errs.ErrNotFound } @@ -68,12 +54,17 @@ func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([] limit = entity.ClampLimit(limit) out := make([]*entity.Relation, 0) for _, rel := range m.relations { - if rel.UserSmall == userID || rel.UserBig == userID { + if rel.UserID == userID && rel.Status == entity.StatusFriend { cp := *rel out = append(out, &cp) } } - sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + sort.Slice(out, func(i, j int) bool { + if out[i].Pinned != out[j].Pinned { + return out[i].Pinned + } + return out[i].CreatedAt.After(out[j].CreatedAt) + }) if len(out) > limit { out = out[:limit] } @@ -125,7 +116,7 @@ func (m *MemoryRepo) UpsertRequest(_ context.Context, req *entity.Request) error return nil } -func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) { +func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status entity.RequestStatus, limit int) ([]*entity.Request, error) { m.mu.Lock() defer m.mu.Unlock() limit = entity.ClampLimit(limit) @@ -147,7 +138,15 @@ func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, lim return out, nil } -func pairOf(a, b int64) pairKey { - small, big := entity.OrderUsers(a, b) - return pairKey{Small: small, Big: big} +func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) { + for _, req := range m.requests { + if req.Status != entity.RequestPending { + continue + } + pair := (req.FromUser == a && req.ToUser == b) || (req.FromUser == b && req.ToUser == a) + if pair { + req.Status = entity.RequestRejected + req.UpdatedAt = at + } + } } diff --git a/internal/module/friend/model/memory_write.go b/internal/module/friend/model/memory_write.go index ce5b91c..268970d 100644 --- a/internal/module/friend/model/memory_write.go +++ b/internal/module/friend/model/memory_write.go @@ -8,76 +8,74 @@ import ( "spark-server/internal/pkg/errs" ) -func (m *MemoryRepo) GetRemark(_ context.Context, userID, friendID int64) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - return m.remarks[remarkKey{UserID: userID, FriendID: friendID}], nil -} - -func (m *MemoryRepo) ListRemarks(_ context.Context, userID int64, friendIDs []int64) (map[int64]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - out := make(map[int64]string, len(friendIDs)) - for _, id := range friendIDs { - if s := m.remarks[remarkKey{UserID: userID, FriendID: id}]; s != "" { - out[id] = s - } - } - return out, nil -} - func (m *MemoryRepo) SetRemark(_ context.Context, userID, friendID int64, remark string) error { + return m.updateFriendEdge(userID, friendID, func(rel *entity.Relation) { + rel.Remark = remark + rel.UpdatedAt = time.Now() + }) +} + +func (m *MemoryRepo) SetPinned(_ context.Context, userID, friendID int64, pinned bool) error { + return m.updateFriendEdge(userID, friendID, func(rel *entity.Relation) { + rel.Pinned = pinned + rel.UpdatedAt = time.Now() + }) +} + +func (m *MemoryRepo) updateFriendEdge(userID, friendID int64, fn func(*entity.Relation)) error { if userID <= 0 || friendID <= 0 { return errs.ErrInvalidArgument } m.mu.Lock() defer m.mu.Unlock() - key := remarkKey{UserID: userID, FriendID: friendID} - if remark == "" { - delete(m.remarks, key) - return nil + rel, ok := m.relations[edgeKey{UserID: userID, FriendID: friendID}] + if !ok || rel.Status != entity.StatusFriend { + return errs.ErrNotFound } - m.remarks[key] = remark + fn(rel) return nil } func (m *MemoryRepo) IsBlocked(_ context.Context, userID, targetID int64) (bool, error) { m.mu.Lock() defer m.mu.Unlock() - _, ok := m.blocks[blockKey{UserID: userID, BlockedID: targetID}] - return ok, nil + rel, ok := m.relations[edgeKey{UserID: userID, FriendID: targetID}] + return ok && rel.Blocked, nil } func (m *MemoryRepo) BlockedEither(_ context.Context, a, b int64) (bool, error) { m.mu.Lock() defer m.mu.Unlock() - _, ab := m.blocks[blockKey{UserID: a, BlockedID: b}] - _, ba := m.blocks[blockKey{UserID: b, BlockedID: a}] - return ab || ba, nil + ab := m.relations[edgeKey{UserID: a, FriendID: b}] + ba := m.relations[edgeKey{UserID: b, FriendID: a}] + return (ab != nil && ab.Blocked) || (ba != nil && ba.Blocked), nil } -func (m *MemoryRepo) CreateBlock(_ context.Context, b *entity.Block) error { - if b == nil || b.UserID <= 0 || b.BlockedID <= 0 { +func (m *MemoryRepo) SetBlocked(_ context.Context, userID, friendID int64, blocked bool) error { + if userID <= 0 || friendID <= 0 { return errs.ErrInvalidArgument } m.mu.Lock() defer m.mu.Unlock() - now := b.CreatedAt - if now.IsZero() { - now = time.Now() + now := time.Now() + key := edgeKey{UserID: userID, FriendID: friendID} + rel, ok := m.relations[key] + if !ok { + if !blocked { + return nil + } + m.relations[key] = &entity.Relation{ + UserID: userID, FriendID: friendID, Status: entity.StatusNone, + Blocked: true, CreatedAt: now, UpdatedAt: now, + } + m.rejectPendingLocked(userID, friendID, now) + return nil + } + rel.Blocked = blocked + rel.UpdatedAt = now + if blocked { + m.rejectPendingLocked(userID, friendID, now) } - m.blocks[blockKey{UserID: b.UserID, BlockedID: b.BlockedID}] = now - delete(m.relations, pairOf(b.UserID, b.BlockedID)) - delete(m.remarks, remarkKey{UserID: b.UserID, FriendID: b.BlockedID}) - delete(m.remarks, remarkKey{UserID: b.BlockedID, FriendID: b.UserID}) - m.rejectPendingLocked(b.UserID, b.BlockedID, now) - return nil -} - -func (m *MemoryRepo) DeleteBlock(_ context.Context, userID, blockedID int64) error { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.blocks, blockKey{UserID: userID, BlockedID: blockedID}) return nil } @@ -93,19 +91,35 @@ func (m *MemoryRepo) CreateReport(_ context.Context, r *entity.Report) error { } func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) error { - if in.Relation == nil { + if len(in.Edges) == 0 { return errs.ErrInvalidArgument } m.mu.Lock() defer m.mu.Unlock() - now := in.Relation.CreatedAt - if now.IsZero() { - now = time.Now() - in.Relation.CreatedAt = now - } - key := pairKey{Small: in.Relation.UserSmall, Big: in.Relation.UserBig} - if _, ok := m.relations[key]; !ok { - cp := *in.Relation + now := time.Now() + for _, e := range in.Edges { + if e == nil || e.UserID <= 0 || e.FriendID <= 0 { + return errs.ErrInvalidArgument + } + if e.CreatedAt.IsZero() { + e.CreatedAt = now + } + if e.UpdatedAt.IsZero() { + e.UpdatedAt = e.CreatedAt + } + key := edgeKey{UserID: e.UserID, FriendID: e.FriendID} + if cur, ok := m.relations[key]; ok { + cur.Status = entity.StatusFriend + cur.UpdatedAt = e.UpdatedAt + if e.Remark != "" { + cur.Remark = e.Remark + } + if e.Source != 0 { + cur.Source = e.Source + } + continue + } + cp := *e m.relations[key] = &cp } for _, id := range in.Requests { @@ -114,38 +128,18 @@ func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) err req.UpdatedAt = now } } - for i := range in.Remarks { - r := in.Remarks[i] - if r.Text == "" { - continue - } - m.remarks[remarkKey{UserID: r.UserID, FriendID: r.FriendID}] = r.Text - } return nil } func (m *MemoryRepo) DeleteFriend(_ context.Context, a, b int64) error { m.mu.Lock() defer m.mu.Unlock() - key := pairOf(a, b) - if _, ok := m.relations[key]; !ok { + _, ab := m.relations[edgeKey{UserID: a, FriendID: b}] + _, ba := m.relations[edgeKey{UserID: b, FriendID: a}] + if !ab && !ba { return errs.ErrNotFound } - delete(m.relations, key) - delete(m.remarks, remarkKey{UserID: a, FriendID: b}) - delete(m.remarks, remarkKey{UserID: b, FriendID: a}) + delete(m.relations, edgeKey{UserID: a, FriendID: b}) + delete(m.relations, edgeKey{UserID: b, FriendID: a}) return nil } - -func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) { - for _, req := range m.requests { - if req.Status != entity.RequestPending { - continue - } - pair := (req.FromUser == a && req.ToUser == b) || (req.FromUser == b && req.ToUser == a) - if pair { - req.Status = entity.RequestRejected - req.UpdatedAt = at - } - } -} diff --git a/internal/module/friend/model/user_block.go b/internal/module/friend/model/user_block.go deleted file mode 100644 index 9ec6898..0000000 --- a/internal/module/friend/model/user_block.go +++ /dev/null @@ -1,77 +0,0 @@ -package model - -import ( - "context" - "time" - - "gorm.io/gorm" - - "spark-server/internal/module/friend/entity" - "spark-server/internal/pkg/errs" -) - -// UserBlock maps table user_blocks. -type UserBlock struct { - UserID int64 `gorm:"column:user_id;primaryKey;comment:拉黑人 user_id"` - BlockedID int64 `gorm:"column:blocked_id;primaryKey;comment:被拉黑人 user_id"` - CreatedAt time.Time `gorm:"column:created_at;comment:拉黑时间"` -} - -func (UserBlock) TableName() string { return "user_blocks" } - -func (m *Repo) IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) { - db, err := m.session(ctx) - if err != nil { - return false, err - } - var n int64 - if err := db.Model(&UserBlock{}).Where("user_id = ? AND blocked_id = ?", userID, targetID).Count(&n).Error; err != nil { - return false, mapDBErr(err) - } - return n > 0, nil -} - -func (m *Repo) BlockedEither(ctx context.Context, a, b int64) (bool, error) { - me, err := m.IsBlocked(ctx, a, b) - if err != nil || me { - return me, err - } - return m.IsBlocked(ctx, b, a) -} - -func (m *Repo) CreateBlock(ctx context.Context, b *entity.Block) error { - if b == nil || b.UserID <= 0 || b.BlockedID <= 0 { - return errs.ErrInvalidArgument - } - db, err := m.session(ctx) - if err != nil { - return err - } - now := b.CreatedAt - if now.IsZero() { - now = time.Now() - } - return db.Transaction(func(tx *gorm.DB) error { - row := UserBlock{UserID: b.UserID, BlockedID: b.BlockedID, CreatedAt: now} - if err := tx.Where("user_id = ? AND blocked_id = ?", b.UserID, b.BlockedID). - Attrs(row).FirstOrCreate(&row).Error; err != nil { - return mapDBErr(err) - } - small, big := entity.OrderUsers(b.UserID, b.BlockedID) - if err := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{}).Error; err != nil { - return mapDBErr(err) - } - if err := deleteRemarksBetween(tx, b.UserID, b.BlockedID); err != nil { - return err - } - return rejectPairRequests(tx, b.UserID, b.BlockedID, now) - }) -} - -func (m *Repo) DeleteBlock(ctx context.Context, userID, blockedID int64) error { - db, err := m.session(ctx) - if err != nil { - return err - } - return mapDBErr(db.Where("user_id = ? AND blocked_id = ?", userID, blockedID).Delete(&UserBlock{}).Error) -} diff --git a/internal/module/group/entity/group.go b/internal/module/group/entity/group.go index 2cb5a10..b054a4c 100644 --- a/internal/module/group/entity/group.go +++ b/internal/module/group/entity/group.go @@ -2,27 +2,37 @@ package entity const ( - StatusActive = 1 - StatusDissolved = 2 + StatusActive Status = 1 + StatusDissolved Status = 2 - MaxNameLen = 32 - MaxMembers = 200 - ListDefault = 20 - ListMax = 100 - MemberListMax = 200 - MaxCollageFaces = 9 + MaxNameLen = 32 + MaxAnnouncementLen = 1024 + MaxRemarkLen = 32 + MaxMembers = 200 + ListDefault = 20 + ListMax = 100 + MemberListMax = 200 + MaxCollageFaces = 9 ) +// Status is persisted as groups.status. +type Status int + +func (s Status) Valid() bool { + return s == StatusActive || s == StatusDissolved +} + // Group is a type-2 chat. ID is the AUTO_INCREMENT 群号; ConversationID is Snowflake. type Group struct { ID int64 ConversationID int64 Name string Avatar string + Announcement string OwnerID int64 MutedAll bool MemberCount int - Status int + Status Status CreatedAtMS int64 UpdatedAtMS int64 } diff --git a/internal/module/group/entity/member.go b/internal/module/group/entity/member.go index 429fe6c..04aa849 100644 --- a/internal/module/group/entity/member.go +++ b/internal/module/group/entity/member.go @@ -1,29 +1,51 @@ package entity const ( - RoleOwner = 1 - RoleAdmin = 2 - RoleMember = 3 + RoleOwner Role = 1 + RoleAdmin Role = 2 + RoleMember Role = 3 ) +// Role is persisted as group_members.role. +type Role int + +func (r Role) Valid() bool { + return r == RoleOwner || r == RoleAdmin || r == RoleMember +} + // Member is a row in group_members. SpeakMuted is send-ban, not 免打扰. type Member struct { GroupID int64 UserID int64 - Role int + Role Role Nickname string + Remark string SpeakMuted bool JoinedAtMS int64 } func ValidRole(role int) bool { - return role == RoleOwner || role == RoleAdmin || role == RoleMember + return Role(role).Valid() } func (m *Member) CanManage() bool { return m != nil && (m.Role == RoleOwner || m.Role == RoleAdmin) } +// CanModerate is kick / mute: owner may act on admin and member; admin may act on member only. +func (m *Member) CanModerate(target *Member) bool { + if m == nil || target == nil || !m.CanManage() { + return false + } + if target.Role == RoleOwner { + return false + } + if target.Role == RoleAdmin && m.Role != RoleOwner { + return false + } + return true +} + func (m *Member) CanSpeak(mutedAll bool) bool { if m == nil || m.SpeakMuted { return false diff --git a/internal/module/group/logic/create.go b/internal/module/group/logic/create.go index 9090eca..a509f4c 100644 --- a/internal/module/group/logic/create.go +++ b/internal/module/group/logic/create.go @@ -47,7 +47,7 @@ func (s *Service) Create(ctx context.Context, ownerID int64, in *CreateInput) (* s.invalidateMembers(g.ConversationID) s.stampAvatar(ctx, g, ownerID, invitees) _ = s.notice(ctx, g, ownerID, entity.SystemKindCreated, invitees) - return viewOf(g, entity.RoleOwner), nil + return viewOf(g, &entity.Member{UserID: ownerID, Role: entity.RoleOwner}), nil } func (s *Service) collectInvitees(ctx context.Context, ownerID int64, uids []int64) ([]int64, error) { diff --git a/internal/module/group/logic/get.go b/internal/module/group/logic/get.go index a8fc602..9cfa877 100644 --- a/internal/module/group/logic/get.go +++ b/internal/module/group/logic/get.go @@ -18,7 +18,7 @@ func (s *Service) Get(ctx context.Context, userID, groupID int64) (*View, error) if err != nil { return nil, err } - return viewOf(g, mem.Role), nil + return viewOf(g, mem), nil } func (s *Service) List(ctx context.Context, userID int64, limit int) (*List, error) { @@ -31,11 +31,8 @@ func (s *Service) List(ctx context.Context, userID int64, limit int) (*List, err } items := make([]*View, 0, len(groups)) for _, g := range groups { - role := 0 - if mem, err := s.repo.GetMember(ctx, g.ID, userID); err == nil { - role = mem.Role - } - items = append(items, viewOf(g, role)) + mem, _ := s.repo.GetMember(ctx, g.ID, userID) + items = append(items, viewOf(g, mem)) } return &List{Items: items}, nil } diff --git a/internal/module/group/logic/group_test.go b/internal/module/group/logic/group_test.go index c4408d5..6282a1e 100644 --- a/internal/module/group/logic/group_test.go +++ b/internal/module/group/logic/group_test.go @@ -106,6 +106,10 @@ func TestCreateGroupAutoIncrementID(t *testing.T) { if err != nil || len(ids) != 3 { t.Fatalf("members %v %v", ids, err) } + gid, err := svc.IDByConversation(ctx, g.ConversationID) + if err != nil || gid != g.ID { + t.Fatalf("group of conv %d %v", gid, err) + } } func TestGroupInviteKickLeaveMute(t *testing.T) { @@ -267,3 +271,87 @@ func (n *noGetStore) Put(_ context.Context, key string, body []byte, _ string) e n.kv[key] = append([]byte(nil), body...) return nil } + +func TestGroupAdminAnnouncementRemarkMentions(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + c := login(t, users, "test03@spark.com", 3) + becomeFriends(t, friends, a, b) + becomeFriends(t, friends, a, c) + becomeFriends(t, friends, b, c) + + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID, c.ID}}) + if err != nil { + t.Fatal(err) + } + name := "新名字" + announce := "今晚九点" + if _, err := svc.Update(ctx, b.ID, g.ID, &UpdateInput{Name: &name, Announcement: &announce}); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("member update %v", err) + } + if _, err := svc.Update(ctx, a.ID, g.ID, &UpdateInput{Name: &name, Announcement: &announce}); err != nil { + t.Fatal(err) + } + got, err := svc.Get(ctx, b.ID, g.ID) + if err != nil || got.Name != name || got.Announcement != announce { + t.Fatalf("profile %+v %v", got, err) + } + + admin := entity.RoleAdmin + if _, err := svc.UpdateMember(ctx, a.ID, g.ID, b.ID, &UpdateMemberInput{Role: &admin}); err != nil { + t.Fatal(err) + } + muted := true + if _, err := svc.UpdateMember(ctx, b.ID, g.ID, c.ID, &UpdateMemberInput{SpeakMuted: &muted}); err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateMember(ctx, b.ID, g.ID, a.ID, &UpdateMemberInput{SpeakMuted: &muted}); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("admin mute owner %v", err) + } + if err := svc.RemoveMember(ctx, b.ID, g.ID, c.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.Invite(ctx, a.ID, g.ID, &InviteInput{UIDs: []int64{c.ID}}); err != nil { + t.Fatal(err) + } + if err := svc.CheckMentions(ctx, c.ID, g.ConversationID, true, nil); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("member at-all %v", err) + } + if _, err := svc.UpdateMember(ctx, a.ID, g.ID, c.ID, &UpdateMemberInput{Role: &admin}); err != nil { + t.Fatal(err) + } + if err := svc.RemoveMember(ctx, b.ID, g.ID, c.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("admin kick admin %v", err) + } + if _, err := svc.UpdateMember(ctx, b.ID, g.ID, c.ID, &UpdateMemberInput{SpeakMuted: &muted}); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("admin mute admin %v", err) + } + + nick := "群里的我" + remark := "周末球局" + if _, err := svc.UpdateMember(ctx, c.ID, g.ID, c.ID, &UpdateMemberInput{Nickname: &nick, Remark: &remark}); err != nil { + t.Fatal(err) + } + me, err := svc.Get(ctx, c.ID, g.ID) + if err != nil || me.GroupNickname != nick || me.Remark != remark { + t.Fatalf("self card %+v %v", me, err) + } + if _, err := svc.UpdateMember(ctx, b.ID, g.ID, c.ID, &UpdateMemberInput{Remark: &remark}); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("others remark %v", err) + } + + if err := svc.CheckMentions(ctx, c.ID, g.ConversationID, false, []int64{a.ID}); err != nil { + t.Fatal(err) + } + if err := svc.CheckMentions(ctx, a.ID, g.ConversationID, true, nil); err != nil { + t.Fatal(err) + } + if err := svc.CheckMentions(ctx, b.ID, g.ConversationID, true, []int64{c.ID}); err != nil { + t.Fatal(err) + } + if err := svc.CheckMentions(ctx, a.ID, g.ConversationID, false, []int64{999}); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("at outsider %v", err) + } +} diff --git a/internal/module/group/logic/invite.go b/internal/module/group/logic/invite.go index 35db369..d57c82c 100644 --- a/internal/module/group/logic/invite.go +++ b/internal/module/group/logic/invite.go @@ -47,9 +47,9 @@ func (s *Service) Invite(ctx context.Context, actorID, groupID int64, in *Invite _ = s.notice(ctx, g, actorID, entity.SystemKindInvite, ids) mem, err := s.requireMember(ctx, groupID, actorID) if err != nil { - return viewOf(g, entity.RoleMember), nil + return viewOf(g, &entity.Member{Role: entity.RoleMember}), nil } - return viewOf(g, mem.Role), nil + return viewOf(g, mem), nil } func (s *Service) newMembers(ctx context.Context, g *entity.Group, actorID int64, uids []int64) ([]*entity.Member, error) { diff --git a/internal/module/group/logic/member_update.go b/internal/module/group/logic/member_update.go index e7af455..dd94fab 100644 --- a/internal/module/group/logic/member_update.go +++ b/internal/module/group/logic/member_update.go @@ -12,9 +12,10 @@ import ( ) type UpdateMemberInput struct { - Role *int `json:"role" validate:"omitempty,oneof=2 3" label:"field.role" errmsg:"oneof:validate.group.role_invalid"` - SpeakMuted *bool `json:"speak_muted"` - Nickname *string `json:"nickname" validate:"omitempty,max=32" label:"field.group_nickname" errmsg:"max:validate.group.nickname_invalid"` + Role *entity.Role `json:"role" validate:"omitempty,oneof=2 3" label:"field.role" errmsg:"oneof:validate.group.role_invalid"` + SpeakMuted *bool `json:"speak_muted"` + Nickname *string `json:"nickname" validate:"omitempty,max=32" label:"field.group_nickname" errmsg:"max:validate.group.nickname_invalid"` + Remark *string `json:"remark" validate:"omitempty,max=32" label:"field.group_remark" errmsg:"max:validate.group.remark_invalid"` } func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID int64, in *UpdateMemberInput) (*MemberView, error) { @@ -27,7 +28,7 @@ func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID i if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } - if in.Role == nil && in.SpeakMuted == nil && in.Nickname == nil { + if in.Role == nil && in.SpeakMuted == nil && in.Nickname == nil && in.Remark == nil { return nil, errs.ErrInvalidArgument } g, err := s.loadActive(ctx, groupID) @@ -51,6 +52,12 @@ func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID i func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member, in *UpdateMemberInput) error { lang := reqctx.Lang(ctx) self := actorID == target.UserID + if in.Remark != nil { + if !self { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil)) + } + target.Remark = strings.TrimSpace(*in.Remark) + } if in.Nickname != nil { if !self { if _, err := s.requireManage(ctx, g.ID, actorID); err != nil { @@ -69,10 +76,11 @@ func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID target.Role = *in.Role } if in.SpeakMuted != nil { - if _, err := s.requireManage(ctx, g.ID, actorID); err != nil { + actor, err := s.requireManage(ctx, g.ID, actorID) + if err != nil { return err } - if target.Role == entity.RoleOwner { + if !actor.CanModerate(target) { return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil)) } target.SpeakMuted = *in.SpeakMuted diff --git a/internal/module/group/logic/members.go b/internal/module/group/logic/members.go index 0a0b77a..761fd38 100644 --- a/internal/module/group/logic/members.go +++ b/internal/module/group/logic/members.go @@ -54,6 +54,17 @@ func (s *Service) MemberIDs(ctx context.Context, conversationID int64) ([]int64, return ids, nil } +func (s *Service) IDByConversation(ctx context.Context, conversationID int64) (int64, error) { + if !s.ready() || conversationID <= 0 { + return 0, errs.ErrInvalidArgument + } + g, err := s.repo.GetByConversation(ctx, conversationID) + if err != nil { + return 0, err + } + return g.ID, nil +} + func (s *Service) CanSend(ctx context.Context, userID, conversationID int64) error { if !s.ready() || userID <= 0 || conversationID <= 0 { return errs.ErrForbidden diff --git a/internal/module/group/logic/mention.go b/internal/module/group/logic/mention.go new file mode 100644 index 0000000..bddd53f --- /dev/null +++ b/internal/module/group/logic/mention.go @@ -0,0 +1,48 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +func (s *Service) CheckMentions(ctx context.Context, userID, conversationID int64, atAll bool, uids []int64) error { + if !s.ready() || userID <= 0 || conversationID <= 0 { + return errs.ErrForbidden + } + if !atAll && len(uids) == 0 { + return nil + } + g, err := s.repo.GetByConversation(ctx, conversationID) + if err != nil || !g.Active() { + return errs.ErrForbidden + } + mem, err := s.requireMember(ctx, g.ID, userID) + if err != nil { + return err + } + lang := reqctx.Lang(ctx) + if atAll && !mem.CanManage() { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupMentionDenied, nil)) + } + if len(uids) == 0 { + return nil + } + ids, err := s.repo.ListMemberIDs(ctx, g.ID) + if err != nil { + return err + } + inGroup := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + inGroup[id] = struct{}{} + } + for _, uid := range entity.UniqueUIDs(uids) { + if _, ok := inGroup[uid]; !ok { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupMentionInvalid, nil)) + } + } + return nil +} diff --git a/internal/module/group/logic/notice.go b/internal/module/group/logic/notice.go index 0acf6a7..792df57 100644 --- a/internal/module/group/logic/notice.go +++ b/internal/module/group/logic/notice.go @@ -27,9 +27,9 @@ func (s *Service) notice(ctx context.Context, g *entity.Group, actorID int64, ki return err } msg := &msgentity.Message{ - ID: msgID, ConversationID: g.ConversationID, SenderID: 0, + ID: msgID, ConversationID: g.ConversationID, SenderID: 0, GroupID: g.ID, ClientMsgID: fmt.Sprintf("sys:group:%d", msgID), - Type: msgentity.TypeSystem, Content: raw, + Type: msgentity.TypeSystem, Kind: msgentity.KindGroup, Content: raw, Status: msgentity.StatusNormal, CreatedAtMS: now.UnixMilli(), } if err := s.msgs.Create(ctx, msg); err != nil { @@ -50,7 +50,7 @@ func (s *Service) notice(ctx context.Context, g *entity.Group, actorID int64, ki FromUserID: actorID, ToUserIDs: to, Payload: reply.MustMarshal(reply.OK(groupPushBody{ MsgID: msgID, ConversationID: g.ConversationID, SenderID: 0, - MessageType: int16(msgentity.TypeSystem), Content: raw, CreatedAt: now.UnixMilli(), + MessageType: msgentity.TypeSystem, Content: raw, CreatedAt: now.UnixMilli(), })), }) } @@ -59,7 +59,7 @@ type groupPushBody struct { MsgID int64 `json:"msg_id"` ConversationID int64 `json:"conversation_id"` SenderID int64 `json:"sender_id"` - MessageType int16 `json:"message_type"` + MessageType msgentity.Type `json:"message_type"` Content json.RawMessage `json:"content"` CreatedAt int64 `json:"created_at"` } diff --git a/internal/module/group/logic/remove.go b/internal/module/group/logic/remove.go index e8e440f..1b126fc 100644 --- a/internal/module/group/logic/remove.go +++ b/internal/module/group/logic/remove.go @@ -47,7 +47,7 @@ func (s *Service) kick(ctx context.Context, g *entity.Group, actorID int64, targ if err != nil { return err } - if target.Role == entity.RoleOwner || (target.Role == entity.RoleAdmin && actor.Role != entity.RoleOwner) { + if !actor.CanModerate(target) { return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil)) } if err := s.repo.RemoveMember(ctx, g.ID, target.UserID); err != nil { diff --git a/internal/module/group/logic/transfer.go b/internal/module/group/logic/transfer.go index 939e9d2..fab1a9e 100644 --- a/internal/module/group/logic/transfer.go +++ b/internal/module/group/logic/transfer.go @@ -42,7 +42,8 @@ func (s *Service) Transfer(ctx context.Context, ownerID, groupID int64, in *Tran g.OwnerID = in.UID s.invalidateMembers(g.ConversationID) _ = s.notice(ctx, g, ownerID, entity.SystemKindTransfer, []int64{in.UID}) - return viewOf(g, entity.RoleMember), nil + mem, _ := s.repo.GetMember(ctx, groupID, ownerID) + return viewOf(g, mem), nil } func (s *Service) Dissolve(ctx context.Context, ownerID, groupID int64) error { diff --git a/internal/module/group/logic/update.go b/internal/module/group/logic/update.go index 6c21c74..cee3a1d 100644 --- a/internal/module/group/logic/update.go +++ b/internal/module/group/logic/update.go @@ -10,9 +10,10 @@ import ( ) type UpdateInput struct { - Name *string `json:"name" validate:"omitempty,min=1,max=32" label:"field.group_name" errmsg:"min:validate.group.name_invalid;max:validate.group.name_invalid"` - Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar"` - MutedAll *bool `json:"muted_all"` + Name *string `json:"name" validate:"omitempty,min=1,max=32" label:"field.group_name" errmsg:"min:validate.group.name_invalid;max:validate.group.name_invalid"` + Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar"` + Announcement *string `json:"announcement" validate:"omitempty,max=1024" label:"field.group_announcement" errmsg:"max:validate.group.announcement_invalid"` + MutedAll *bool `json:"muted_all"` } func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateInput) (*View, error) { @@ -25,7 +26,7 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } - if in.Name == nil && in.Avatar == nil && in.MutedAll == nil { + if in.Name == nil && in.Avatar == nil && in.Announcement == nil && in.MutedAll == nil { return nil, errs.ErrInvalidArgument } g, err := s.loadActive(ctx, groupID) @@ -45,6 +46,9 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI } g.Avatar = av } + if in.Announcement != nil { + g.Announcement = strings.TrimSpace(*in.Announcement) + } if in.MutedAll != nil { g.MutedAll = *in.MutedAll } @@ -56,5 +60,5 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI if err != nil { return nil, err } - return viewOf(g, mem.Role), nil + return viewOf(g, mem), nil } diff --git a/internal/module/group/logic/view.go b/internal/module/group/logic/view.go index 2a2df18..1194203 100644 --- a/internal/module/group/logic/view.go +++ b/internal/module/group/logic/view.go @@ -9,27 +9,30 @@ import ( // View is the HTTP/TCP group card. ID is the AUTO_INCREMENT 群号. type View struct { - ID int64 `json:"id"` - ConversationID int64 `json:"conversation_id"` - Name string `json:"name"` - Avatar string `json:"avatar"` - OwnerID int64 `json:"owner_id"` - MutedAll bool `json:"muted_all"` - MemberCount int `json:"member_count"` - Role int `json:"role"` - CreatedAt int64 `json:"created_at"` + ID int64 `json:"id"` + ConversationID int64 `json:"conversation_id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Announcement string `json:"announcement"` + OwnerID int64 `json:"owner_id"` + MutedAll bool `json:"muted_all"` + MemberCount int `json:"member_count"` + Role entity.Role `json:"role"` + Remark string `json:"remark"` + GroupNickname string `json:"group_nickname"` + CreatedAt int64 `json:"created_at"` } type MemberView struct { - UID int64 `json:"uid"` - SparkID string `json:"spark_id"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - Gender int `json:"gender"` - Role int `json:"role"` - GroupNickname string `json:"group_nickname"` - SpeakMuted bool `json:"speak_muted"` - JoinedAt int64 `json:"joined_at"` + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Gender userentity.Gender `json:"gender"` + Role entity.Role `json:"role"` + GroupNickname string `json:"group_nickname"` + SpeakMuted bool `json:"speak_muted"` + JoinedAt int64 `json:"joined_at"` } type List struct { @@ -40,15 +43,21 @@ type MemberList struct { Items []*MemberView `json:"items"` } -func viewOf(g *entity.Group, role int) *View { +func viewOf(g *entity.Group, mem *entity.Member) *View { if g == nil { return nil } - return &View{ + v := &View{ ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, - OwnerID: g.OwnerID, MutedAll: g.MutedAll, MemberCount: g.MemberCount, - Role: role, CreatedAt: g.CreatedAtMS, + Announcement: g.Announcement, OwnerID: g.OwnerID, MutedAll: g.MutedAll, + MemberCount: g.MemberCount, CreatedAt: g.CreatedAtMS, } + if mem != nil { + v.Role = mem.Role + v.Remark = mem.Remark + v.GroupNickname = mem.Nickname + } + return v } func memberViewOf(mem *entity.Member, u *userentity.User) *MemberView { diff --git a/internal/module/group/model/group.go b/internal/module/group/model/group.go index ef6e347..4ffc737 100644 --- a/internal/module/group/model/group.go +++ b/internal/module/group/model/group.go @@ -14,6 +14,7 @@ type Group struct { ConversationID int64 `gorm:"column:conversation_id;uniqueIndex;comment:对应 conversations.id(Snowflake),发消息与同步用"` Name string `gorm:"column:name;comment:群名称"` Avatar string `gorm:"column:avatar;comment:群头像对象存储 key,空表示未设置"` + Announcement string `gorm:"column:announcement;comment:群公告,仅群主/管理员可改"` OwnerID int64 `gorm:"column:owner_id;comment:群主 user_id"` MutedAll int `gorm:"column:muted_all;comment:0=未全员禁言 1=全员禁言(群主/管理员仍可发言)"` MemberCount int `gorm:"column:member_count;comment:当前成员数,含群主"` @@ -29,8 +30,8 @@ func (r *Group) entity() *entity.Group { return nil } g := &entity.Group{ - ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, OwnerID: r.OwnerID, - MemberCount: r.MemberCount, Status: r.Status, + ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, Announcement: r.Announcement, OwnerID: r.OwnerID, + MemberCount: r.MemberCount, Status: entity.Status(r.Status), CreatedAtMS: r.CreatedAt.UnixMilli(), UpdatedAtMS: r.UpdatedAt.UnixMilli(), } g.MutedAll = r.MutedAll != 0 @@ -44,15 +45,15 @@ func groupRow(g *entity.Group) *Group { updated = now } row := &Group{ - ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, OwnerID: g.OwnerID, - MemberCount: g.MemberCount, Status: g.Status, + ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, Announcement: g.Announcement, OwnerID: g.OwnerID, + MemberCount: g.MemberCount, Status: int(g.Status), CreatedAt: now, UpdatedAt: updated, } if g.MutedAll { row.MutedAll = 1 } if row.Status == 0 { - row.Status = entity.StatusActive + row.Status = int(entity.StatusActive) } return row } @@ -120,10 +121,11 @@ func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error { return err } res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{ - "name": g.Name, - "avatar": g.Avatar, - "muted_all": boolInt(g.MutedAll), - "updated_at": msTime(g.UpdatedAtMS), + "name": g.Name, + "avatar": g.Avatar, + "announcement": g.Announcement, + "muted_all": boolInt(g.MutedAll), + "updated_at": msTime(g.UpdatedAtMS), }) if res.Error != nil { return mapDBErr(res.Error) diff --git a/internal/module/group/model/group_member.go b/internal/module/group/model/group_member.go index c031004..4a4a6d0 100644 --- a/internal/module/group/model/group_member.go +++ b/internal/module/group/model/group_member.go @@ -14,6 +14,7 @@ type GroupMember struct { UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"` Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"` Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"` + Remark string `gorm:"column:remark;comment:成员对该群的备注,仅自己可见"` SpeakMuted int `gorm:"column:speak_muted;comment:0=未禁言 1=被单独禁言"` JoinedAt time.Time `gorm:"column:joined_at;comment:入群时间"` } @@ -25,8 +26,8 @@ func (r *GroupMember) entity() *entity.Member { return nil } m := &entity.Member{ - GroupID: r.GroupID, UserID: r.UserID, Role: r.Role, - Nickname: r.Nickname, JoinedAtMS: r.JoinedAt.UnixMilli(), + GroupID: r.GroupID, UserID: r.UserID, Role: entity.Role(r.Role), + Nickname: r.Nickname, Remark: r.Remark, JoinedAtMS: r.JoinedAt.UnixMilli(), } m.SpeakMuted = r.SpeakMuted != 0 return m @@ -34,14 +35,14 @@ func (r *GroupMember) entity() *entity.Member { func memberRow(m *entity.Member) *GroupMember { row := &GroupMember{ - GroupID: m.GroupID, UserID: m.UserID, Role: m.Role, - Nickname: m.Nickname, JoinedAt: msTime(m.JoinedAtMS), + GroupID: m.GroupID, UserID: m.UserID, Role: int(m.Role), + Nickname: m.Nickname, Remark: m.Remark, JoinedAt: msTime(m.JoinedAtMS), } if m.SpeakMuted { row.SpeakMuted = 1 } if row.Role == 0 { - row.Role = entity.RoleMember + row.Role = int(entity.RoleMember) } return row } @@ -109,6 +110,7 @@ func (m *Repo) UpdateMember(ctx context.Context, mem *entity.Member) error { res := db.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", mem.GroupID, mem.UserID).Updates(map[string]any{ "role": mem.Role, "nickname": mem.Nickname, + "remark": mem.Remark, "speak_muted": boolInt(mem.SpeakMuted), }) if res.Error != nil { diff --git a/internal/module/group/model/memory.go b/internal/module/group/model/memory.go index a30d014..d1fe7f2 100644 --- a/internal/module/group/model/memory.go +++ b/internal/module/group/model/memory.go @@ -140,6 +140,7 @@ func (m *MemoryRepo) UpdateProfile(_ context.Context, g *entity.Group) error { } cur.Name = g.Name cur.Avatar = g.Avatar + cur.Announcement = g.Announcement cur.MutedAll = g.MutedAll cur.UpdatedAtMS = g.UpdatedAtMS if cur.UpdatedAtMS <= 0 { @@ -164,6 +165,7 @@ func (m *MemoryRepo) UpdateMember(_ context.Context, mem *entity.Member) error { } cur.Role = mem.Role cur.Nickname = mem.Nickname + cur.Remark = mem.Remark cur.SpeakMuted = mem.SpeakMuted return nil } diff --git a/internal/module/group/model/write.go b/internal/module/group/model/write.go index be8fbf3..9c7598a 100644 --- a/internal/module/group/model/write.go +++ b/internal/module/group/model/write.go @@ -23,7 +23,7 @@ func (m *Repo) CreateGroup(ctx context.Context, g *entity.Group, members []*enti now := msTime(g.CreatedAtMS) return db.Transaction(func(tx *gorm.DB) error { conv := convmodel.Conversation{ - ID: g.ConversationID, Type: conventity.TypeGroup, LastMsgAt: now, CreatedAt: now, + ID: g.ConversationID, Type: int(conventity.TypeGroup), LastMsgAt: now, CreatedAt: now, } if err := tx.Create(&conv).Error; err != nil { return mapDBErr(err) diff --git a/internal/module/inbox/entity/preview.go b/internal/module/inbox/entity/preview.go index b4275fa..46978f5 100644 --- a/internal/module/inbox/entity/preview.go +++ b/internal/module/inbox/entity/preview.go @@ -1,4 +1,3 @@ -// Package entity holds inbox preview types. List/unread live in Redis, not MySQL. package entity type Preview struct { @@ -6,3 +5,17 @@ type Preview struct { MsgID int64 Snippet []byte } + +// DialogHit is a ZSET member updated after dialog_score. +type DialogHit struct { + ConversationID int64 + Score int64 +} + +// Dialog is one conversation row for SYNC_INBOX. +type Dialog struct { + ConversationID int64 `json:"conversation_id"` + Score int64 `json:"score"` + Unread int64 `json:"unread"` + Preview []byte `json:"preview"` +} diff --git a/internal/module/inbox/logic/store.go b/internal/module/inbox/logic/store.go index 3438ff0..3a3e1b2 100644 --- a/internal/module/inbox/logic/store.go +++ b/internal/module/inbox/logic/store.go @@ -1,12 +1,17 @@ // Package logic is the Redis inbox (conversation list) contract. No MySQL on this hot path. package logic -import "context" +import ( + "context" + + "spark-server/internal/module/inbox/entity" +) type Store interface { TouchUnread(ctx context.Context, userID, conversationID int64, score int64) error TouchRead(ctx context.Context, userID, conversationID int64, score int64) error IncrUnread(ctx context.Context, userID, conversationID int64, delta int64) error SetPreview(ctx context.Context, conversationID int64, preview []byte) error - ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]int64, error) + ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, error) + LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error) } diff --git a/internal/module/inbox/model/memory.go b/internal/module/inbox/model/memory.go new file mode 100644 index 0000000..5ee2830 --- /dev/null +++ b/internal/module/inbox/model/memory.go @@ -0,0 +1,122 @@ +package model + +import ( + "context" + "sync" + + "spark-server/internal/module/inbox/entity" +) + +type dialog struct { + unread map[int64]int64 + read map[int64]int64 + unreadCnt map[int64]int64 +} + +// MemoryStore is an in-process inbox for tests. +type MemoryStore struct { + mu sync.Mutex + byUser map[int64]*dialog + previews map[int64][]byte +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{byUser: make(map[int64]*dialog), previews: make(map[int64][]byte)} +} + +func (s *MemoryStore) user(uid int64) *dialog { + d, ok := s.byUser[uid] + if !ok { + d = &dialog{unread: map[int64]int64{}, read: map[int64]int64{}, unreadCnt: map[int64]int64{}} + s.byUser[uid] = d + } + return d +} + +func (s *MemoryStore) TouchUnread(_ context.Context, userID, conversationID, score int64) error { + s.mu.Lock() + defer s.mu.Unlock() + d := s.user(userID) + delete(d.read, conversationID) + d.unread[conversationID] = score + return nil +} + +func (s *MemoryStore) TouchRead(_ context.Context, userID, conversationID, score int64) error { + s.mu.Lock() + defer s.mu.Unlock() + d := s.user(userID) + delete(d.unread, conversationID) + d.read[conversationID] = score + return nil +} + +func (s *MemoryStore) IncrUnread(_ context.Context, userID, conversationID, delta int64) error { + s.mu.Lock() + defer s.mu.Unlock() + d := s.user(userID) + d.unreadCnt[conversationID] += delta + return nil +} + +func (s *MemoryStore) SetPreview(_ context.Context, conversationID int64, preview []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + s.previews[conversationID] = append([]byte(nil), preview...) + return nil +} + +func (s *MemoryStore) ListUpdatedSince(_ context.Context, userID, score int64, limit int) ([]entity.DialogHit, error) { + s.mu.Lock() + defer s.mu.Unlock() + d := s.user(userID) + var out []entity.DialogHit + seen := map[int64]struct{}{} + add := func(cid, sc int64) { + if sc <= score { + return + } + if _, ok := seen[cid]; ok { + return + } + seen[cid] = struct{}{} + out = append(out, entity.DialogHit{ConversationID: cid, Score: sc}) + } + for cid, sc := range d.unread { + add(cid, sc) + } + for cid, sc := range d.read { + add(cid, sc) + } + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *MemoryStore) LoadDialogs(_ context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]entity.Dialog, 0, len(hits)) + for _, hit := range hits { + out = append(out, entity.Dialog{ + ConversationID: hit.ConversationID, + Score: hit.Score, + Unread: s.user(userID).unreadCnt[hit.ConversationID], + Preview: append([]byte(nil), s.previews[hit.ConversationID]...), + }) + } + return out, nil +} + +func (s *MemoryStore) Preview(conversationID int64) []byte { + s.mu.Lock() + defer s.mu.Unlock() + return append([]byte(nil), s.previews[conversationID]...) +} + +func (s *MemoryStore) UnreadCount(userID, conversationID int64) int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.user(userID).unreadCnt[conversationID] +} diff --git a/internal/module/inbox/model/redis.go b/internal/module/inbox/model/redis.go new file mode 100644 index 0000000..0feb253 --- /dev/null +++ b/internal/module/inbox/model/redis.go @@ -0,0 +1,130 @@ +package model + +import ( + "context" + "fmt" + "strconv" + + "github.com/redis/go-redis/v9" + + "spark-server/internal/module/inbox/entity" +) + +// RedisStore is C7 dialog list / unread / preview. No MySQL. +type RedisStore struct { + rdb *redis.Client +} + +func NewRedisStore(rdb *redis.Client) *RedisStore { + return &RedisStore{rdb: rdb} +} + +func unreadKey(uid int64) string { return fmt.Sprintf("im:dialog:unread:%d", uid) } +func readKey(uid int64) string { return fmt.Sprintf("im:dialog:read:%d", uid) } +func countKey(uid int64) string { return fmt.Sprintf("im:dialog:unreadcnt:%d", uid) } +func previewKey(cid int64) string { + return fmt.Sprintf("im:dialog:preview:%d", cid) +} + +func (s *RedisStore) TouchUnread(ctx context.Context, userID, conversationID, score int64) error { + if s == nil || s.rdb == nil { + return nil + } + member := strconv.FormatInt(conversationID, 10) + pipe := s.rdb.Pipeline() + pipe.ZAdd(ctx, unreadKey(userID), redis.Z{Score: float64(score), Member: member}) + pipe.ZRem(ctx, readKey(userID), member) + _, err := pipe.Exec(ctx) + return err +} + +func (s *RedisStore) TouchRead(ctx context.Context, userID, conversationID, score int64) error { + if s == nil || s.rdb == nil { + return nil + } + member := strconv.FormatInt(conversationID, 10) + pipe := s.rdb.Pipeline() + pipe.ZAdd(ctx, readKey(userID), redis.Z{Score: float64(score), Member: member}) + pipe.ZRem(ctx, unreadKey(userID), member) + _, err := pipe.Exec(ctx) + return err +} + +func (s *RedisStore) IncrUnread(ctx context.Context, userID, conversationID, delta int64) error { + if s == nil || s.rdb == nil { + return nil + } + return s.rdb.HIncrBy(ctx, countKey(userID), strconv.FormatInt(conversationID, 10), delta).Err() +} + +func (s *RedisStore) SetPreview(ctx context.Context, conversationID int64, preview []byte) error { + if s == nil || s.rdb == nil { + return nil + } + return s.rdb.Set(ctx, previewKey(conversationID), preview, 0).Err() +} + +func (s *RedisStore) ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, error) { + if s == nil || s.rdb == nil { + return nil, nil + } + if limit <= 0 { + limit = 200 + } + minScore := fmt.Sprintf("(%d", score) + unread, err := s.rdb.ZRangeByScoreWithScores(ctx, unreadKey(userID), &redis.ZRangeBy{Min: minScore, Max: "+inf", Count: int64(limit)}).Result() + if err != nil { + return nil, err + } + read, err := s.rdb.ZRangeByScoreWithScores(ctx, readKey(userID), &redis.ZRangeBy{Min: minScore, Max: "+inf", Count: int64(limit)}).Result() + if err != nil { + return nil, err + } + out := make([]entity.DialogHit, 0, len(unread)+len(read)) + seen := map[int64]struct{}{} + for _, part := range [][]redis.Z{unread, read} { + for _, z := range part { + member, _ := z.Member.(string) + id, convErr := strconv.ParseInt(member, 10, 64) + if convErr != nil { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, entity.DialogHit{ConversationID: id, Score: int64(z.Score)}) + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *RedisStore) LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error) { + if s == nil || s.rdb == nil || len(hits) == 0 { + return nil, nil + } + pipe := s.rdb.Pipeline() + previewCmds := make([]*redis.StringCmd, len(hits)) + countCmds := make([]*redis.StringCmd, len(hits)) + ck := countKey(userID) + for i, hit := range hits { + previewCmds[i] = pipe.Get(ctx, previewKey(hit.ConversationID)) + countCmds[i] = pipe.HGet(ctx, ck, strconv.FormatInt(hit.ConversationID, 10)) + } + _, _ = pipe.Exec(ctx) + out := make([]entity.Dialog, 0, len(hits)) + for i, hit := range hits { + d := entity.Dialog{ConversationID: hit.ConversationID, Score: hit.Score} + if raw, err := previewCmds[i].Bytes(); err == nil { + d.Preview = raw + } + if n, err := countCmds[i].Int64(); err == nil { + d.Unread = n + } + out = append(out, d) + } + return out, nil +} diff --git a/internal/module/message/entity/content.go b/internal/module/message/entity/content.go index 66ad0d5..d3bf3be 100644 --- a/internal/module/message/entity/content.go +++ b/internal/module/message/entity/content.go @@ -11,6 +11,9 @@ import ( // Content is the in-memory message body. Persist only Marshal() bytes into messages.content. type Content struct { Type Type `json:"t"` + ReplyTo int64 `json:"reply_to,omitempty"` + AtAll bool `json:"at_all,omitempty"` + AtUIDs []int64 `json:"at_uids,omitempty"` Text *Text `json:"text,omitempty"` Image *Image `json:"image,omitempty"` Voice *Voice `json:"voice,omitempty"` @@ -101,6 +104,12 @@ func (c *Content) Validate() error { if !c.Type.Valid() { return fmt.Errorf("content: unknown type %d", c.Type) } + if c.ReplyTo < 0 { + return fmt.Errorf("content: reply_to") + } + if err := validateMentions(c); err != nil { + return err + } got, n := c.populated() if n != 1 { return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n) @@ -185,6 +194,32 @@ func validateText(t *Text) error { return nil } +func validateMentions(c *Content) error { + if c.Type == TypeSystem && (c.AtAll || len(c.AtUIDs) > 0) { + return fmt.Errorf("content: mentions") + } + if len(c.AtUIDs) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(c.AtUIDs)) + out := make([]int64, 0, len(c.AtUIDs)) + for _, id := range c.AtUIDs { + if id <= 0 { + return fmt.Errorf("content: at_uids") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + if len(out) > MaxAtUIDs { + return fmt.Errorf("content: at_uids") + } + c.AtUIDs = out + return nil +} + func validateMediaKey(kind, key string) error { if strings.TrimSpace(key) == "" { return fmt.Errorf("content: %s key empty", kind) diff --git a/internal/module/message/entity/content_test.go b/internal/module/message/entity/content_test.go index b2909b7..283d5ba 100644 --- a/internal/module/message/entity/content_test.go +++ b/internal/module/message/entity/content_test.go @@ -26,6 +26,22 @@ func TestTextMarshalIsJSONBytesNotRawString(t *testing.T) { if got.Type != TypeText || got.Text == nil || got.Text.Body != "hello" { t.Fatalf("%+v", got) } + if got.ReplyTo != 0 { + t.Fatalf("reply_to %d", got.ReplyTo) + } +} + +func TestReplyToRoundTrip(t *testing.T) { + c := NewText("re") + c.ReplyTo = 42 + raw, err := c.Marshal() + if err != nil { + t.Fatal(err) + } + got, err := UnmarshalContent(raw) + if err != nil || got.ReplyTo != 42 || got.Text.Body != "re" { + t.Fatalf("%v %+v", err, got) + } } func TestMediaTypesRequireObjectKey(t *testing.T) { @@ -86,6 +102,25 @@ func TestMessagePayloadChecksColumnType(t *testing.T) { } } +func TestMentionsRoundTrip(t *testing.T) { + c := NewText("hello @all") + c.AtAll = true + c.AtUIDs = []int64{2, 2, 3} + raw, err := c.Marshal() + if err != nil { + t.Fatal(err) + } + got, err := UnmarshalContent(raw) + if err != nil || !got.AtAll || len(got.AtUIDs) != 2 || got.AtUIDs[0] != 2 || got.AtUIDs[1] != 3 { + t.Fatalf("%v %+v", err, got) + } + sys := NewSystem(&System{Kind: "x", Body: "y"}) + sys.AtAll = true + if _, err := sys.Marshal(); err == nil { + t.Fatal("system mentions") + } +} + func TestObjectKeysAndReplace(t *testing.T) { img := NewImage(&Image{Key: "chat/a.jpg", ThumbKey: "chat/t.jpg"}) keys := img.ObjectKeys() diff --git a/internal/module/message/entity/message.go b/internal/module/message/entity/message.go index bcd52d3..367c00c 100644 --- a/internal/module/message/entity/message.go +++ b/internal/module/message/entity/message.go @@ -2,15 +2,30 @@ package entity import "fmt" +// Kind selects private_message vs group_message. Same numbers as conversation.Type; not a SQL column. +type Kind int + +const ( + KindPrivate Kind = 1 + KindGroup Kind = 2 +) + +func (k Kind) Valid() bool { + return k == KindPrivate || k == KindGroup +} + // Message is a persisted IM body. Content is serialized JSON bytes, not a string. type Message struct { ID int64 ConversationID int64 SenderID int64 + ReceiverID int64 // private_message only; 0 for system + GroupID int64 // group_message only; groups.id 群号 ClientMsgID string Type Type + Kind Kind Content []byte - Status int + Status Status CreatedAtMS int64 } diff --git a/internal/module/message/entity/type.go b/internal/module/message/entity/type.go index 51323f6..06497bf 100644 --- a/internal/module/message/entity/type.go +++ b/internal/module/message/entity/type.go @@ -21,10 +21,20 @@ func (t Type) Valid() bool { } const ( - StatusNormal = 1 - StatusRecalled = 2 - StatusHidden = 3 + StatusNormal Status = 1 + StatusRecalled Status = 2 + StatusHidden Status = 3 ) +// Status is persisted as private_message/group_message.status. +type Status int + +func (s Status) Valid() bool { + return s == StatusNormal || s == StatusRecalled || s == StatusHidden +} + // MaxContentBytes is the serialized content size cap (matches TCP Frame max). const MaxContentBytes = 65536 + +// MaxAtUIDs is how many members a group message may @ (excluding @all). +const MaxAtUIDs = 50 diff --git a/internal/module/message/logic/content.go b/internal/module/message/logic/content.go deleted file mode 100644 index 253ba46..0000000 --- a/internal/module/message/logic/content.go +++ /dev/null @@ -1,19 +0,0 @@ -package logic - -import ( - "fmt" - - "spark-server/internal/module/message/entity" -) - -// EncodeContent serializes a typed payload to BLOB bytes. Callers must store the bytes, not a string. -func EncodeContent(c *entity.Content) (entity.Type, []byte, error) { - if c == nil { - return 0, nil, fmt.Errorf("content: required") - } - raw, err := c.Marshal() - if err != nil { - return 0, nil, err - } - return c.Type, raw, nil -} diff --git a/internal/module/message/logic/delete.go b/internal/module/message/logic/delete.go new file mode 100644 index 0000000..fdb8bff --- /dev/null +++ b/internal/module/message/logic/delete.go @@ -0,0 +1,46 @@ +package logic + +import ( + "context" + "errors" + + deliverentity "spark-server/internal/module/deliver/entity" + "spark-server/internal/pkg/errs" +) + +const cmdDelete uint16 = 15 + +func (s *Service) DeleteMessage(ctx context.Context, userID int64, deviceID uint64, msgID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if msgID <= 0 { + return errs.ErrInvalidArgument + } + msg, err := s.repo.GetByID(ctx, msgID) + if err != nil { + return err + } + if _, err := s.chats.GetMember(ctx, msg.ConversationID, userID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return errs.ErrForbidden + } + return err + } + if err := s.repo.HideForUser(ctx, userID, msg); err != nil { + return err + } + if s.queue == nil { + return nil + } + jobID, err := s.ids.NextID() + if err != nil { + return err + } + return s.queue.Push(ctx, &deliverentity.Job{ + MsgID: jobID, Cmd: cmdDelete, ConversationID: msg.ConversationID, + FromUserID: userID, FromDeviceID: deviceID, ExcludeDevice: deviceID, + ToUserIDs: []int64{userID}, NotPush: true, + Payload: controlBytes(msg.ID, msg.ConversationID), + }) +} diff --git a/internal/module/message/logic/history.go b/internal/module/message/logic/history.go new file mode 100644 index 0000000..6755e6c --- /dev/null +++ b/internal/module/message/logic/history.go @@ -0,0 +1,51 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/pkg/errs" +) + +type HistoryRequest struct { + ConversationID int64 + LastMsgID int64 + Limit int +} + +type HistoryResponse struct { + Items []*Item `json:"items"` + HasMore bool `json:"has_more"` +} + +func (s *Service) GetHistory(ctx context.Context, userID int64, req HistoryRequest) (*HistoryResponse, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if req.ConversationID <= 0 { + return nil, errs.ErrInvalidArgument + } + if _, err := s.chats.GetMember(ctx, req.ConversationID, userID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return nil, errs.ErrForbidden + } + return nil, err + } + limit := req.Limit + if limit <= 0 || limit > defaultHistoryLimit { + limit = defaultHistoryLimit + } + rows, err := s.repo.ListByConversation(ctx, req.ConversationID, userID, req.LastMsgID, limit+1) + if err != nil { + return nil, err + } + hasMore := len(rows) > limit + if hasMore { + rows = rows[:limit] + } + items := make([]*Item, 0, len(rows)) + for _, row := range rows { + items = append(items, itemOf(row)) + } + return &HistoryResponse{Items: items, HasMore: hasMore}, nil +} diff --git a/internal/module/message/logic/message_test.go b/internal/module/message/logic/message_test.go new file mode 100644 index 0000000..4c3533a --- /dev/null +++ b/internal/module/message/logic/message_test.go @@ -0,0 +1,256 @@ +package logic + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "spark-server/internal/infrastructure/id" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + deliverentity "spark-server/internal/module/deliver/entity" + delivermodel "spark-server/internal/module/deliver/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + msgentity "spark-server/internal/module/message/entity" + msgmodel "spark-server/internal/module/message/model" + userentity "spark-server/internal/module/user/entity" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +func testMessageSvc(t *testing.T) (*Service, *userlogic.Service, *friendlogic.Service, *delivermodel.MemoryQueue) { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + chats := convmodel.NewMemoryP2P() + p2p := convlogic.NewP2PService(chats, ids) + q := delivermodel.NewMemoryQueue() + msgs := msgmodel.NewMemoryRepo() + friends := friendlogic.NewService( + friendmodel.NewMemoryRepo(), users, ids, + friendlogic.NewGreeter(p2p, msgs, ids, q), + ) + return NewService(msgs, chats, p2p, friends, nil, ids, q), users, friends, q +} + +func twoUsers(t *testing.T, users *userlogic.Service) (*userentity.User, *userentity.User) { + t.Helper() + ctx := t.Context() + a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, DeviceType: userentity.DeviceWeb}) + if err != nil { + t.Fatal(err) + } + return a, b +} + +func TestSendReplyRecallDelete(t *testing.T) { + svc, users, friends, q := testMessageSvc(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{Language: "zh-CN", RealIP: "203.0.113.10"}) + a, b := twoUsers(t, users) + applied, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{UID: b.ID, Reason: "hi"}) + if err != nil { + t.Fatal(err) + } + if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + + text := json.RawMessage(`{"t":1,"text":{"body":"hi"}}`) + first, err := svc.SendMessage(ctx, a.ID, 1, &SendInput{PeerID: b.ID, ClientMsgID: "c1", Content: text}) + if err != nil { + t.Fatal(err) + } + if first.ConversationID == 0 { + t.Fatal("conversation_id") + } + job, err := takeCmd(ctx, q, cmdMessage, first.MsgID) + if err != nil || job == nil || job.ExcludeDevice != 1 { + t.Fatalf("deliver %+v %v", job, err) + } + again, err := svc.SendMessage(ctx, a.ID, 1, &SendInput{PeerID: b.ID, ClientMsgID: "c1", Content: text}) + if err != nil || again.MsgID != first.MsgID { + t.Fatalf("idempotent %+v %v", again, err) + } + + replyJSON, err := json.Marshal(map[string]any{"t": 1, "reply_to": first.MsgID, "text": map[string]string{"body": "re"}}) + if err != nil { + t.Fatal(err) + } + replied, err := svc.SendMessage(ctx, b.ID, 2, &SendInput{PeerID: a.ID, ClientMsgID: "c2", Content: replyJSON}) + if err != nil { + t.Fatal(err) + } + + msg, err := svc.repo.GetByID(ctx, first.MsgID) + if err != nil { + t.Fatal(err) + } + if msg.ReceiverID != b.ID { + t.Fatalf("receiver %d want %d", msg.ReceiverID, b.ID) + } + hist, err := svc.GetHistory(ctx, a.ID, HistoryRequest{ConversationID: msg.ConversationID}) + if err != nil || len(hist.Items) < 2 { + t.Fatalf("history %+v %v", hist, err) + } + foundKind := false + for _, it := range hist.Items { + if it.MsgID == first.MsgID && it.Kind == msgentity.KindPrivate { + foundKind = true + } + } + if !foundKind { + t.Fatal("private_message kind") + } + caught, err := svc.GetHistory(ctx, a.ID, HistoryRequest{ + ConversationID: msg.ConversationID, LastMsgID: hist.Items[len(hist.Items)-1].MsgID, + }) + if err != nil || len(caught.Items) != 0 || caught.HasMore { + t.Fatalf("incremental %+v %v", caught, err) + } + + if _, err := svc.SendMessage(ctx, a.ID, 1, &SendInput{ + PeerID: b.ID, ClientMsgID: "bad", Content: json.RawMessage(`{"t":1,"reply_to":1,"text":{"body":"x"}}`), + }); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("missing reply target %v", err) + } + + if err := svc.RecallMessage(ctx, b.ID, 2, first.MsgID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("not sender %v", err) + } + if err := svc.RecallMessage(ctx, a.ID, 1, first.MsgID); err != nil { + t.Fatal(err) + } + recJob, err := takeCmd(ctx, q, cmdRecall, 0) + if err != nil || recJob == nil || recJob.MsgID == first.MsgID { + t.Fatalf("recall job %+v %v", recJob, err) + } + got, err := svc.repo.GetByID(ctx, first.MsgID) + if err != nil || got.Status != msgentity.StatusRecalled { + t.Fatalf("recalled %+v %v", got, err) + } + + oldID, err := svc.ids.NextID() + if err != nil { + t.Fatal(err) + } + old := &msgentity.Message{ + ID: oldID, ConversationID: msg.ConversationID, SenderID: a.ID, ClientMsgID: "old", + Type: msgentity.TypeText, Content: []byte(`{"t":1,"text":{"body":"old"}}`), + Status: msgentity.StatusNormal, CreatedAtMS: time.Now().Add(-3 * time.Minute).UnixMilli(), + } + if err := svc.repo.Create(ctx, old); err != nil { + t.Fatal(err) + } + if err := svc.RecallMessage(ctx, a.ID, 1, old.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("window %v", err) + } + + if err := svc.DeleteMessage(ctx, a.ID, 1, replied.MsgID); err != nil { + t.Fatal(err) + } + delJob, err := takeCmd(ctx, q, cmdDelete, 0) + if err != nil || delJob == nil || len(delJob.ToUserIDs) != 1 || delJob.ToUserIDs[0] != a.ID { + t.Fatalf("delete job %+v %v", delJob, err) + } + after, err := svc.GetHistory(ctx, a.ID, HistoryRequest{ConversationID: msg.ConversationID}) + if err != nil { + t.Fatal(err) + } + for _, it := range after.Items { + if it.MsgID == replied.MsgID { + t.Fatal("hidden for deleter") + } + } + peer, err := svc.GetHistory(ctx, b.ID, HistoryRequest{ConversationID: msg.ConversationID}) + if err != nil { + t.Fatal(err) + } + seen := false + for _, it := range peer.Items { + if it.MsgID == replied.MsgID { + seen = true + } + } + if !seen { + t.Fatal("peer still sees message") + } +} + +func TestSendRequiresFriend(t *testing.T) { + svc, users, _, _ := testMessageSvc(t) + ctx := t.Context() + a, b := twoUsers(t, users) + _, err := svc.SendMessage(ctx, a.ID, 1, &SendInput{ + PeerID: b.ID, ClientMsgID: "x", Content: json.RawMessage(`{"t":1,"text":{"body":"no"}}`), + }) + if !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("want forbidden got %v", err) + } +} + +func TestSendRejectsStaleMsgID(t *testing.T) { + svc, users, friends, _ := testMessageSvc(t) + ctx := t.Context() + a, b := twoUsers(t, users) + applied, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{UID: b.ID}) + if err != nil { + t.Fatal(err) + } + if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + _, err = svc.SendMessage(ctx, a.ID, 1, &SendInput{ + PeerID: b.ID, ClientMsgID: "stale", MsgID: 1, Content: json.RawMessage(`{"t":1,"text":{"body":"x"}}`), + }) + if !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("stale msg_id %v", err) + } +} + +func TestP2PRejectsGroupMentions(t *testing.T) { + svc, users, friends, _ := testMessageSvc(t) + ctx := t.Context() + a, b := twoUsers(t, users) + applied, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{UID: b.ID}) + if err != nil { + t.Fatal(err) + } + if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + _, err = svc.SendMessage(ctx, a.ID, 1, &SendInput{ + PeerID: b.ID, ClientMsgID: "at", Content: json.RawMessage(`{"t":1,"at_all":true,"text":{"body":"hi"}}`), + }) + if !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("p2p at-all %v", err) + } +} + +func takeCmd(ctx context.Context, q *delivermodel.MemoryQueue, cmd uint16, msgID int64) (*deliverentity.Job, error) { + for i := 0; i < 8; i++ { + job, err := q.Take(ctx, 50*time.Millisecond) + if err != nil { + return nil, err + } + if job == nil { + return nil, errs.ErrNotFound + } + if job.Cmd != cmd || (msgID != 0 && job.MsgID != msgID) { + continue + } + return job, nil + } + return nil, errs.ErrNotFound +} diff --git a/internal/module/message/logic/recall.go b/internal/module/message/logic/recall.go new file mode 100644 index 0000000..3e511e4 --- /dev/null +++ b/internal/module/message/logic/recall.go @@ -0,0 +1,68 @@ +package logic + +import ( + "context" + "errors" + "time" + + deliverentity "spark-server/internal/module/deliver/entity" + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +const cmdRecall uint16 = 9 + +func (s *Service) RecallMessage(ctx context.Context, userID int64, deviceID uint64, msgID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if msgID <= 0 { + return errs.ErrInvalidArgument + } + msg, err := s.repo.GetByID(ctx, msgID) + if err != nil { + return err + } + if msg.SenderID != userID || msg.SenderID == 0 { + return errs.ErrForbidden + } + if _, err := s.chats.GetMember(ctx, msg.ConversationID, userID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return errs.ErrForbidden + } + return err + } + if msg.Status == entity.StatusRecalled { + return s.enqueueRecall(ctx, msg, userID, deviceID) + } + if msg.Status != entity.StatusNormal { + return errs.ErrForbidden + } + if time.Since(time.UnixMilli(msg.CreatedAtMS)) > recallWindow { + return errs.ErrForbidden + } + if err := s.repo.Recall(ctx, msg.ID); err != nil { + return err + } + msg.Status = entity.StatusRecalled + return s.enqueueRecall(ctx, msg, userID, deviceID) +} + +func (s *Service) enqueueRecall(ctx context.Context, msg *entity.Message, userID int64, deviceID uint64) error { + if s.queue == nil { + return nil + } + jobID, err := s.ids.NextID() + if err != nil { + return err + } + members, err := s.chats.ListMemberIDs(ctx, msg.ConversationID) + if err != nil { + return err + } + return s.queue.Push(ctx, &deliverentity.Job{ + MsgID: jobID, Cmd: cmdRecall, ConversationID: msg.ConversationID, + FromUserID: userID, FromDeviceID: deviceID, ExcludeDevice: deviceID, + ToUserIDs: members, Payload: controlBytes(msg.ID, msg.ConversationID), Preview: previewOf(msg), + }) +} diff --git a/internal/module/message/logic/send.go b/internal/module/message/logic/send.go index e2ea751..d56e926 100644 --- a/internal/module/message/logic/send.go +++ b/internal/module/message/logic/send.go @@ -1,36 +1,94 @@ -// Package logic is the message send and history use-case layer. package logic import ( "context" + "encoding/json" + "time" + deliverentity "spark-server/internal/module/deliver/entity" "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" ) -type Repository interface { - Create(ctx context.Context, message *entity.Message) error - GetByID(ctx context.Context, id int64) (*entity.Message, error) - GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) - ListByConversation(ctx context.Context, conversationID, lastMsgID int64, limit int) ([]*entity.Message, error) -} +const cmdMessage uint16 = 6 -type SendRequest struct { - ConversationID int64 - SenderID int64 - DeviceID uint64 - ClientMsgID string - MsgID int64 - Content *entity.Content +type SendInput struct { + ConversationID int64 `json:"conversation_id"` + PeerID int64 `json:"peer_id"` + ClientMsgID string `json:"client_msg_id" validate:"required,max=64" label:"field.client_msg_id" errmsg:"required:validate.message.client_msg_required"` + MsgID int64 `json:"msg_id"` + Content json.RawMessage `json:"content" validate:"required" label:"field.content" errmsg:"required:validate.message.content_required"` } type SendResponse struct { - MsgID int64 - ClientMsgID string - ServerTimeMS int64 + MsgID int64 `json:"msg_id"` + ConversationID int64 `json:"conversation_id"` + ClientMsgID string `json:"client_msg_id"` + ServerTimeMS int64 `json:"server_time"` } -// Service owns persist + enqueue. SEND_ACK happens only after both succeed. -type Service interface { - GenerateMsgID(ctx context.Context) (int64, error) - SendMessage(ctx context.Context, req *SendRequest) (*SendResponse, error) +func (s *Service) SendMessage(ctx context.Context, userID int64, deviceID uint64, in *SendInput) (*SendResponse, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + content, err := entity.UnmarshalContent(in.Content) + if err != nil { + return nil, errs.ErrInvalidArgument + } + conv, memberIDs, err := s.prepareSend(ctx, userID, in) + if err != nil { + return nil, err + } + if err := s.checkReplyTo(ctx, conv.ID, content.ReplyTo); err != nil { + return nil, err + } + if err := s.checkMentions(ctx, userID, conv, content); err != nil { + return nil, err + } + raw, err := content.Marshal() + if err != nil { + return nil, errs.ErrInvalidArgument + } + now := time.Now() + msg := &entity.Message{ + ConversationID: conv.ID, SenderID: userID, ClientMsgID: in.ClientMsgID, + Type: content.Type, Kind: kindOfConv(conv), Content: raw, Status: entity.StatusNormal, CreatedAtMS: now.UnixMilli(), + } + if err := s.stampTargets(ctx, userID, conv, memberIDs, msg); err != nil { + return nil, err + } + if err := s.assignID(in.MsgID, now, msg); err != nil { + return nil, err + } + ack, dup, err := s.persistSend(ctx, msg) + if err != nil { + return nil, err + } + if err := s.enqueueMessage(ctx, ack, memberIDs, userID, deviceID, dup); err != nil { + return nil, err + } + return &SendResponse{MsgID: ack.ID, ConversationID: ack.ConversationID, ClientMsgID: ack.ClientMsgID, ServerTimeMS: ack.CreatedAtMS}, nil +} + +func (s *Service) enqueueMessage(ctx context.Context, msg *entity.Message, memberIDs []int64, userID int64, deviceID uint64, retry bool) error { + if s.queue == nil { + return nil + } + job := &deliverentity.Job{ + MsgID: msg.ID, Cmd: cmdMessage, ConversationID: msg.ConversationID, + FromUserID: userID, FromDeviceID: deviceID, ExcludeDevice: deviceID, + ToUserIDs: memberIDs, Payload: pushBytes(msg), Preview: previewOf(msg), + } + if retry { + job.NotMailbox = true + } + return s.queue.Push(ctx, job) } diff --git a/internal/module/message/logic/send_perm.go b/internal/module/message/logic/send_perm.go new file mode 100644 index 0000000..bfff6c7 --- /dev/null +++ b/internal/module/message/logic/send_perm.go @@ -0,0 +1,139 @@ +package logic + +import ( + "context" + "errors" + + conventity "spark-server/internal/module/conversation/entity" + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) prepareSend(ctx context.Context, userID int64, in *SendInput) (*conventity.Conversation, []int64, error) { + if in.ConversationID <= 0 && in.PeerID <= 0 { + return nil, nil, errs.ErrInvalidArgument + } + convID := in.ConversationID + if convID <= 0 { + if s.p2p == nil || s.friends == nil || in.PeerID <= 0 { + return nil, nil, errs.ErrForbidden + } + if err := s.friends.CanSend(ctx, userID, in.PeerID); err != nil { + return nil, nil, err + } + id, err := s.p2p.EnsureP2P(ctx, userID, in.PeerID) + if err != nil { + return nil, nil, err + } + convID = id + } + if _, err := s.chats.GetMember(ctx, convID, userID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return nil, nil, errs.ErrForbidden + } + return nil, nil, err + } + conv, err := s.chats.Get(ctx, convID) + if err != nil { + return nil, nil, err + } + memberIDs, err := s.recipientIDs(ctx, userID, conv) + if err != nil { + return nil, nil, err + } + return conv, memberIDs, nil +} + +func (s *Service) stampTargets(ctx context.Context, userID int64, conv *conventity.Conversation, memberIDs []int64, msg *entity.Message) error { + if conv == nil || msg == nil { + return nil + } + switch conv.Type { + case conventity.TypeP2P: + msg.ReceiverID = otherMember(userID, memberIDs) + case conventity.TypeGroup: + if s.groups == nil { + return errs.ErrForbidden + } + gid, err := s.groups.IDByConversation(ctx, conv.ID) + if err != nil { + return err + } + msg.GroupID = gid + } + return nil +} + +func kindOfConv(conv *conventity.Conversation) entity.Kind { + if conv != nil && conv.Type == conventity.TypeGroup { + return entity.KindGroup + } + return entity.KindPrivate +} + +func (s *Service) recipientIDs(ctx context.Context, userID int64, conv *conventity.Conversation) ([]int64, error) { + switch conv.Type { + case conventity.TypeP2P: + ids, err := s.chats.ListMemberIDs(ctx, conv.ID) + if err != nil { + return nil, err + } + peer := otherMember(userID, ids) + if peer <= 0 || s.friends == nil { + return nil, errs.ErrForbidden + } + if err := s.friends.CanSend(ctx, userID, peer); err != nil { + return nil, err + } + return ids, nil + case conventity.TypeGroup: + if s.groups == nil { + return nil, errs.ErrForbidden + } + if err := s.groups.CanSend(ctx, userID, conv.ID); err != nil { + return nil, err + } + return s.groups.MemberIDs(ctx, conv.ID) + default: + return nil, errs.ErrForbidden + } +} + +func (s *Service) checkReplyTo(ctx context.Context, convID, replyTo int64) error { + if replyTo == 0 { + return nil + } + target, err := s.repo.GetByID(ctx, replyTo) + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return errs.ErrInvalidArgument + } + return err + } + if target.ConversationID != convID { + return errs.ErrInvalidArgument + } + return nil +} + +func (s *Service) checkMentions(ctx context.Context, userID int64, conv *conventity.Conversation, content *entity.Content) error { + if content == nil || (!content.AtAll && len(content.AtUIDs) == 0) { + return nil + } + if conv == nil || conv.Type != conventity.TypeGroup { + return errs.ErrInvalidArgument + } + if s.groups == nil { + return errs.ErrForbidden + } + return s.groups.CheckMentions(ctx, userID, conv.ID, content.AtAll, content.AtUIDs) +} + +func otherMember(self int64, ids []int64) int64 { + for _, id := range ids { + if id != self { + return id + } + } + return 0 +} diff --git a/internal/module/message/logic/send_persist.go b/internal/module/message/logic/send_persist.go new file mode 100644 index 0000000..63c29b5 --- /dev/null +++ b/internal/module/message/logic/send_persist.go @@ -0,0 +1,45 @@ +package logic + +import ( + "context" + "errors" + "time" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) assignID(requested int64, now time.Time, msg *entity.Message) error { + if requested > 0 { + if !id.WithinSkew(requested, now, msgIDSkew) { + return errs.ErrInvalidArgument + } + msg.ID = requested + return nil + } + nid, err := s.ids.NextID() + if err != nil { + return err + } + msg.ID = nid + return nil +} + +func (s *Service) persistSend(ctx context.Context, msg *entity.Message) (*entity.Message, bool, error) { + err := s.repo.CreateAndBump(ctx, msg) + if err == nil { + return msg, false, nil + } + if !errors.Is(err, errs.ErrDuplicate) { + return nil, false, err + } + existing, lookErr := s.repo.GetByClientMsgID(ctx, msg.SenderID, msg.ConversationID, msg.ClientMsgID) + if lookErr != nil { + existing, lookErr = s.repo.GetByID(ctx, msg.ID) + } + if lookErr != nil { + return nil, false, err + } + return existing, true, nil +} diff --git a/internal/module/message/logic/service.go b/internal/module/message/logic/service.go new file mode 100644 index 0000000..c4008e2 --- /dev/null +++ b/internal/module/message/logic/service.go @@ -0,0 +1,74 @@ +package logic + +import ( + "context" + "time" + + conventity "spark-server/internal/module/conversation/entity" + deliverentity "spark-server/internal/module/deliver/entity" + "spark-server/internal/module/message/entity" +) + +const ( + defaultHistoryLimit = 200 + recallWindow = 2 * time.Minute + msgIDSkew = 5 * time.Minute +) + +type Repository interface { + Create(ctx context.Context, message *entity.Message) error + CreateAndBump(ctx context.Context, message *entity.Message) error + GetByID(ctx context.Context, id int64) (*entity.Message, error) + GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) + ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error) + Recall(ctx context.Context, id int64) error + HideForUser(ctx context.Context, userID int64, msg *entity.Message) error +} + +type Conversations interface { + Get(ctx context.Context, id int64) (*conventity.Conversation, error) + GetMember(ctx context.Context, conversationID, userID int64) (*conventity.Member, error) + ListMemberIDs(ctx context.Context, conversationID int64) ([]int64, error) +} + +type P2POpener interface { + EnsureP2P(ctx context.Context, userA, userB int64) (int64, error) +} + +type FriendGate interface { + CanSend(ctx context.Context, fromUserID, toUserID int64) error +} + +type GroupGate interface { + CanSend(ctx context.Context, userID, conversationID int64) error + MemberIDs(ctx context.Context, conversationID int64) ([]int64, error) + CheckMentions(ctx context.Context, userID, conversationID int64, atAll bool, uids []int64) error + IDByConversation(ctx context.Context, conversationID int64) (int64, error) +} + +type IDGen interface { + NextID() (int64, error) +} + +type DeliverQueue interface { + Push(ctx context.Context, job *deliverentity.Job) error +} + +// Service is send / recall / delete / history. HTTP and TCP share it. +type Service struct { + repo Repository + chats Conversations + p2p P2POpener + friends FriendGate + groups GroupGate + ids IDGen + queue DeliverQueue +} + +func NewService(repo Repository, chats Conversations, p2p P2POpener, friends FriendGate, groups GroupGate, ids IDGen, queue DeliverQueue) *Service { + return &Service{repo: repo, chats: chats, p2p: p2p, friends: friends, groups: groups, ids: ids, queue: queue} +} + +func (s *Service) ready() bool { + return s != nil && s.repo != nil && s.chats != nil && s.ids != nil +} diff --git a/internal/module/message/logic/view.go b/internal/module/message/logic/view.go new file mode 100644 index 0000000..d9db870 --- /dev/null +++ b/internal/module/message/logic/view.go @@ -0,0 +1,54 @@ +package logic + +import ( + "encoding/json" + + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/reply" +) + +// Item is the HTTP/TCP message view. Content is serialized JSON bytes. +type Item struct { + MsgID int64 `json:"msg_id"` + ConversationID int64 `json:"conversation_id"` + SenderID int64 `json:"sender_id"` + ReceiverID int64 `json:"receiver_id,omitempty"` + GroupID int64 `json:"group_id,omitempty"` + ClientMsgID string `json:"client_msg_id,omitempty"` + MessageType entity.Type `json:"message_type"` + Kind entity.Kind `json:"kind,omitempty"` + Content json.RawMessage `json:"content"` + Status entity.Status `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +func itemOf(m *entity.Message) *Item { + if m == nil { + return nil + } + return &Item{ + MsgID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, + ReceiverID: m.ReceiverID, GroupID: m.GroupID, ClientMsgID: m.ClientMsgID, + MessageType: m.Type, Kind: m.Kind, Content: append(json.RawMessage(nil), m.Content...), Status: m.Status, CreatedAt: m.CreatedAtMS, + } +} + +func pushBytes(m *entity.Message) []byte { + return reply.MustMarshal(reply.OK(itemOf(m))) +} + +func controlBytes(msgID, conversationID int64) []byte { + return reply.MustMarshal(reply.OK(map[string]int64{ + "msg_id": msgID, "conversation_id": conversationID, + })) +} + +func previewOf(m *entity.Message) []byte { + if m == nil { + return nil + } + raw, _ := json.Marshal(map[string]any{ + "msg_id": m.ID, "t": m.Type, "sender_id": m.SenderID, "status": m.Status, + }) + return raw +} diff --git a/internal/module/message/model/group_message.go b/internal/module/message/model/group_message.go new file mode 100644 index 0000000..b46bfd7 --- /dev/null +++ b/internal/module/message/model/group_message.go @@ -0,0 +1,20 @@ +package model + +import "spark-server/internal/module/message/entity" + +// GroupMessage maps table group_message. +type GroupMessage struct { + messageRow + GroupID int64 `gorm:"column:group_id;comment:群号 groups.id,便于按群查询"` +} + +func (GroupMessage) TableName() string { return tableGroupMessage } + +func (r *GroupMessage) entity() *entity.Message { + if r == nil { + return nil + } + m := r.messageRow.entity(entity.KindGroup) + m.GroupID = r.GroupID + return m +} diff --git a/internal/module/message/model/memory.go b/internal/module/message/model/memory.go index c831024..b323646 100644 --- a/internal/module/message/model/memory.go +++ b/internal/module/message/model/memory.go @@ -8,30 +8,66 @@ import ( "spark-server/internal/pkg/errs" ) +type clientKey struct { + SenderID int64 + ConversationID int64 + ClientMsgID string +} + +type hideKey struct { + UserID int64 + MsgID int64 +} + // MemoryRepo is an in-process message store for tests and empty-DSN bring-up. type MemoryRepo struct { - mu sync.Mutex - byID map[int64]*entity.Message + mu sync.Mutex + byID map[int64]*entity.Message + byClient map[clientKey]int64 + hidden map[hideKey]struct{} } func NewMemoryRepo() *MemoryRepo { - return &MemoryRepo{byID: make(map[int64]*entity.Message)} + return &MemoryRepo{ + byID: make(map[int64]*entity.Message), + byClient: make(map[clientKey]int64), + hidden: make(map[hideKey]struct{}), + } +} + +func (m *MemoryRepo) clone(row *entity.Message) *entity.Message { + cp := *row + if cp.Content != nil { + cp.Content = append([]byte(nil), row.Content...) + } + return &cp } func (m *MemoryRepo) Create(_ context.Context, message *entity.Message) error { + return m.put(message, false) +} + +func (m *MemoryRepo) CreateAndBump(_ context.Context, message *entity.Message) error { + return m.put(message, true) +} + +func (m *MemoryRepo) put(message *entity.Message, _ bool) error { if message == nil || message.ID <= 0 { return errs.ErrInvalidArgument } m.mu.Lock() defer m.mu.Unlock() + key := clientKey{SenderID: message.SenderID, ConversationID: message.ConversationID, ClientMsgID: message.ClientMsgID} if _, ok := m.byID[message.ID]; ok { return errs.ErrDuplicate } - cp := *message - if cp.Content != nil { - cp.Content = append([]byte(nil), message.Content...) + if message.ClientMsgID != "" { + if _, ok := m.byClient[key]; ok { + return errs.ErrDuplicate + } + m.byClient[key] = message.ID } - m.byID[message.ID] = &cp + m.byID[message.ID] = m.clone(message) return nil } @@ -42,11 +78,61 @@ func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.Message, erro if !ok { return nil, errs.ErrNotFound } - cp := *row - if cp.Content != nil { - cp.Content = append([]byte(nil), row.Content...) + return m.clone(row), nil +} + +func (m *MemoryRepo) GetByClientMsgID(_ context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.byClient[clientKey{SenderID: senderID, ConversationID: conversationID, ClientMsgID: clientMsgID}] + if !ok { + return nil, errs.ErrNotFound } - return &cp, nil + return m.clone(m.byID[id]), nil +} + +func (m *MemoryRepo) ListByConversation(_ context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 { + limit = 200 + } + out := make([]*entity.Message, 0) + for _, row := range m.byID { + if row.ConversationID != conversationID || row.ID <= lastMsgID || row.Status == entity.StatusHidden { + continue + } + if _, ok := m.hidden[hideKey{UserID: viewerID, MsgID: row.ID}]; ok { + continue + } + out = append(out, m.clone(row)) + } + sortMessages(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) Recall(_ context.Context, id int64) error { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.byID[id] + if !ok || row.Status != entity.StatusNormal { + return errs.ErrNotFound + } + row.Status = entity.StatusRecalled + return nil +} + +func (m *MemoryRepo) HideForUser(_ context.Context, userID int64, msg *entity.Message) error { + if userID <= 0 || msg == nil || msg.ID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + m.hidden[hideKey{UserID: userID, MsgID: msg.ID}] = struct{}{} + return nil } func (m *MemoryRepo) All() []*entity.Message { @@ -54,11 +140,17 @@ func (m *MemoryRepo) All() []*entity.Message { defer m.mu.Unlock() out := make([]*entity.Message, 0, len(m.byID)) for _, row := range m.byID { - cp := *row - if cp.Content != nil { - cp.Content = append([]byte(nil), row.Content...) - } - out = append(out, &cp) + out = append(out, m.clone(row)) } return out } + +func sortMessages(rows []*entity.Message) { + for i := 1; i < len(rows); i++ { + j := i + for j > 0 && rows[j-1].ID > rows[j].ID { + rows[j-1], rows[j] = rows[j], rows[j-1] + j-- + } + } +} diff --git a/internal/module/message/model/message_delete.go b/internal/module/message/model/message_delete.go new file mode 100644 index 0000000..a29376c --- /dev/null +++ b/internal/module/message/model/message_delete.go @@ -0,0 +1,13 @@ +package model + +import "time" + +// MessageDelete maps table message_deletes. PK is (user_id, msg_id). +type MessageDelete struct { + UserID int64 `gorm:"column:user_id;primaryKey;comment:删除者 user_id,仅该用户历史不可见"` + MsgID int64 `gorm:"column:msg_id;primaryKey;comment:被删除的 Snowflake MsgId"` + ConversationID int64 `gorm:"column:conversation_id;comment:所属会话,便于按会话过滤"` + CreatedAt time.Time `gorm:"column:created_at;comment:删除时间"` +} + +func (MessageDelete) TableName() string { return "message_deletes" } diff --git a/internal/module/message/model/private_message.go b/internal/module/message/model/private_message.go new file mode 100644 index 0000000..8840928 --- /dev/null +++ b/internal/module/message/model/private_message.go @@ -0,0 +1,20 @@ +package model + +import "spark-server/internal/module/message/entity" + +// PrivateMessage maps table private_message. +type PrivateMessage struct { + messageRow + ReceiverID int64 `gorm:"column:receiver_id;comment:接收者 user_id;系统消息为 0"` +} + +func (PrivateMessage) TableName() string { return tablePrivateMessage } + +func (r *PrivateMessage) entity() *entity.Message { + if r == nil { + return nil + } + m := r.messageRow.entity(entity.KindPrivate) + m.ReceiverID = r.ReceiverID + return m +} diff --git a/internal/module/message/model/query.go b/internal/module/message/model/query.go new file mode 100644 index 0000000..b37f38c --- /dev/null +++ b/internal/module/message/model/query.go @@ -0,0 +1,112 @@ +package model + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error) { + if conversationID <= 0 || viewerID <= 0 { + return nil, errs.ErrInvalidArgument + } + if limit <= 0 { + limit = 200 + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + kind, err := m.lookupKind(db, conversationID) + if err != nil { + return nil, err + } + table := tableOf(kind) + if kind == entity.KindGroup { + var rows []GroupMessage + err = db.Table(table).Select(table+".*"). + Joins("LEFT JOIN message_deletes AS d ON d.msg_id = "+table+".id AND d.user_id = ?", viewerID). + Where(table+".conversation_id = ? AND "+table+".id > ? AND "+table+".status <> ? AND d.msg_id IS NULL", conversationID, lastMsgID, entity.StatusHidden). + Order(table + ".id ASC"). + Limit(limit). + Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Message, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil + } + var rows []PrivateMessage + err = db.Table(table).Select(table+".*"). + Joins("LEFT JOIN message_deletes AS d ON d.msg_id = "+table+".id AND d.user_id = ?", viewerID). + Where(table+".conversation_id = ? AND "+table+".id > ? AND "+table+".status <> ? AND d.msg_id IS NULL", conversationID, lastMsgID, entity.StatusHidden). + Order(table + ".id ASC"). + Limit(limit). + Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Message, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil +} + +func (m *Repo) Recall(ctx context.Context, id int64) error { + if id <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + if n, err := m.recallTable(db, tablePrivateMessage, id); err != nil { + return err + } else if n > 0 { + return nil + } + n, err := m.recallTable(db, tableGroupMessage, id) + if err != nil { + return err + } + if n == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) recallTable(db *gorm.DB, table string, id int64) (int64, error) { + res := db.Table(table).Where("id = ? AND status = ?", id, entity.StatusNormal).Update("status", entity.StatusRecalled) + if res.Error != nil { + return 0, mapDBErr(res.Error) + } + return res.RowsAffected, nil +} + +func (m *Repo) HideForUser(ctx context.Context, userID int64, msg *entity.Message) error { + if userID <= 0 || msg == nil || msg.ID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := MessageDelete{ + UserID: userID, MsgID: msg.ID, ConversationID: msg.ConversationID, + } + if err := db.Create(&row).Error; err != nil { + if errors.Is(mapDBErr(err), errs.ErrDuplicate) { + return nil + } + return mapDBErr(err) + } + return nil +} diff --git a/internal/module/message/model/repo.go b/internal/module/message/model/repo.go index 719d50a..58f7c94 100644 --- a/internal/module/message/model/repo.go +++ b/internal/module/message/model/repo.go @@ -8,11 +8,12 @@ import ( mysqldriver "github.com/go-sql-driver/mysql" "gorm.io/gorm" + conventity "spark-server/internal/module/conversation/entity" "spark-server/internal/module/message/entity" "spark-server/internal/pkg/errs" ) -// Repo is MySQL persistence for messages. +// Repo is MySQL persistence for private_message and group_message. type Repo struct { gdb *gorm.DB } @@ -42,6 +43,32 @@ func mapDBErr(err error) error { return errs.ErrInner.WithError("message-mysql", err) } +func (m *Repo) lookupKind(db *gorm.DB, conversationID int64) (entity.Kind, error) { + var row struct{ Type int } + if err := db.Table("conversations").Select("type").Where("id = ?", conversationID).Take(&row).Error; err != nil { + return 0, mapDBErr(err) + } + if conventity.Type(row.Type) == conventity.TypeGroup { + return entity.KindGroup, nil + } + return entity.KindPrivate, nil +} + +func (m *Repo) takeByID(db *gorm.DB, table string, kind entity.Kind, id int64) (*entity.Message, error) { + if kind == entity.KindGroup { + var row GroupMessage + if err := db.Table(table).Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil + } + var row PrivateMessage + if err := db.Table(table).Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + func (m *Repo) GetByID(ctx context.Context, id int64) (*entity.Message, error) { if id <= 0 { return nil, errs.ErrInvalidArgument @@ -50,26 +77,147 @@ func (m *Repo) GetByID(ctx context.Context, id int64) (*entity.Message, error) { if err != nil { return nil, err } - var row Message - if err := db.Where("id = ?", id).Take(&row).Error; err != nil { - return nil, mapDBErr(err) + msg, err := m.takeByID(db, tablePrivateMessage, entity.KindPrivate, id) + if err == nil || !errors.Is(err, errs.ErrNotFound) { + return msg, err } - return row.Entity(), nil + return m.takeByID(db, tableGroupMessage, entity.KindGroup, id) } func (m *Repo) Create(ctx context.Context, message *entity.Message) error { - if message == nil || message.ID <= 0 { + if message == nil || message.ID <= 0 || message.ConversationID <= 0 { return errs.ErrInvalidArgument } db, err := m.session(ctx) if err != nil { return err } - row := FromEntity(message) + kind, err := m.lookupKind(db, message.ConversationID) + if err != nil { + return err + } + message.Kind = kind + row := rowFrom(message) + stampCreatedAt(message, &row) + return m.insertBody(db, message, row) +} + +func (m *Repo) GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) { + if senderID <= 0 || conversationID <= 0 || clientMsgID == "" { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + kind, err := m.lookupKind(db, conversationID) + if err != nil { + return nil, err + } + return m.takeByClient(db, kind, senderID, conversationID, clientMsgID) +} + +func (m *Repo) takeByClient(db *gorm.DB, kind entity.Kind, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) { + table := tableOf(kind) + if kind == entity.KindGroup { + var row GroupMessage + err := db.Table(table).Where("sender_id = ? AND conversation_id = ? AND client_msg_id = ?", senderID, conversationID, clientMsgID).Take(&row).Error + if err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil + } + var row PrivateMessage + err := db.Table(table).Where("sender_id = ? AND conversation_id = ? AND client_msg_id = ?", senderID, conversationID, clientMsgID).Take(&row).Error + if err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) CreateAndBump(ctx context.Context, message *entity.Message) error { + if message == nil || message.ID <= 0 || message.ConversationID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := rowFrom(message) + stampCreatedAt(message, &row) + return db.Transaction(func(tx *gorm.DB) error { + kind, err := m.lookupKind(tx, message.ConversationID) + if err != nil { + return err + } + message.Kind = kind + if err := m.insertBody(tx, message, row); err != nil { + return err + } + res := tx.Table("conversations").Where("id = ?", message.ConversationID).Updates(map[string]any{ + "last_msg_id": message.ID, + "last_msg_at": row.CreatedAt, + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil + }) +} + +func stampCreatedAt(message *entity.Message, row *messageRow) { if message.CreatedAtMS > 0 { row.CreatedAt = time.UnixMilli(message.CreatedAtMS) - } else { - row.CreatedAt = time.Now() + return } - return mapDBErr(db.Create(row).Error) + row.CreatedAt = time.Now() + message.CreatedAtMS = row.CreatedAt.UnixMilli() +} + +func (m *Repo) insertBody(db *gorm.DB, message *entity.Message, row messageRow) error { + if message.Kind == entity.KindGroup { + if err := m.fillGroupID(db, message); err != nil { + return err + } + rec := groupRow(message, row) + return mapDBErr(db.Create(&rec).Error) + } + if err := m.fillReceiverID(db, message); err != nil { + return err + } + rec := privateRow(message, row) + return mapDBErr(db.Create(&rec).Error) +} + +func (m *Repo) fillGroupID(db *gorm.DB, message *entity.Message) error { + if message.GroupID > 0 { + return nil + } + var gid int64 + if err := db.Table("groups").Select("id").Where("conversation_id = ?", message.ConversationID).Scan(&gid).Error; err != nil { + return mapDBErr(err) + } + if gid <= 0 { + return errs.ErrNotFound + } + message.GroupID = gid + return nil +} + +func (m *Repo) fillReceiverID(db *gorm.DB, message *entity.Message) error { + if message.ReceiverID > 0 || message.SenderID <= 0 { + return nil + } + var peer int64 + err := db.Table("conversation_members").Select("user_id"). + Where("conversation_id = ? AND user_id <> ?", message.ConversationID, message.SenderID). + Limit(1).Scan(&peer).Error + if err != nil { + return mapDBErr(err) + } + message.ReceiverID = peer + return nil } diff --git a/internal/module/message/model/message.go b/internal/module/message/model/row.go similarity index 65% rename from internal/module/message/model/message.go rename to internal/module/message/model/row.go index 197d137..4aca80d 100644 --- a/internal/module/message/model/message.go +++ b/internal/module/message/model/row.go @@ -1,4 +1,3 @@ -// Package model is message persistence. One file per table. package model import ( @@ -7,8 +6,12 @@ import ( "spark-server/internal/module/message/entity" ) -// Message maps table messages. content is a serialized BLOB, not a string. -type Message struct { +const ( + tablePrivateMessage = "private_message" + tableGroupMessage = "group_message" +) + +type messageRow struct { ID int64 `gorm:"column:id;primaryKey;comment:Snowflake MsgId,主键、排序键、同步游标"` ConversationID int64 `gorm:"column:conversation_id;comment:所属会话 id"` SenderID int64 `gorm:"column:sender_id;comment:发送者 user_id"` @@ -19,9 +22,7 @@ type Message struct { CreatedAt time.Time `gorm:"column:created_at;comment:服务端入库时间"` } -func (Message) TableName() string { return "messages" } - -func (r *Message) Entity() *entity.Message { +func (r *messageRow) entity(kind entity.Kind) *entity.Message { if r == nil { return nil } @@ -31,23 +32,39 @@ func (r *Message) Entity() *entity.Message { SenderID: r.SenderID, ClientMsgID: r.ClientMsgID, Type: entity.Type(r.MessageType), + Kind: kind, Content: r.Content, - Status: r.Status, + Status: entity.Status(r.Status), CreatedAtMS: r.CreatedAt.UnixMilli(), } } -func FromEntity(m *entity.Message) *Message { +func rowFrom(m *entity.Message) messageRow { if m == nil { - return nil + return messageRow{} } - return &Message{ + return messageRow{ ID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, ClientMsgID: m.ClientMsgID, MessageType: int16(m.Type), Content: m.Content, - Status: m.Status, + Status: int(m.Status), } } + +func privateRow(m *entity.Message, base messageRow) PrivateMessage { + return PrivateMessage{messageRow: base, ReceiverID: m.ReceiverID} +} + +func groupRow(m *entity.Message, base messageRow) GroupMessage { + return GroupMessage{messageRow: base, GroupID: m.GroupID} +} + +func tableOf(kind entity.Kind) string { + if kind == entity.KindGroup { + return tableGroupMessage + } + return tablePrivateMessage +} diff --git a/internal/module/message/model/row_test.go b/internal/module/message/model/row_test.go new file mode 100644 index 0000000..596a33b --- /dev/null +++ b/internal/module/message/model/row_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "testing" + + "spark-server/internal/module/message/entity" +) + +func TestTableOf(t *testing.T) { + if tableOf(entity.KindGroup) != tableGroupMessage { + t.Fatal("group") + } + if tableOf(entity.KindPrivate) != tablePrivateMessage { + t.Fatal("private") + } + if tableOf(0) != tablePrivateMessage { + t.Fatal("default private") + } +} + +func TestQueryKeyRows(t *testing.T) { + priv := PrivateMessage{messageRow: messageRow{ID: 1, SenderID: 2}, ReceiverID: 3} + if got := priv.entity(); got.ReceiverID != 3 || got.Kind != entity.KindPrivate { + t.Fatalf("%+v", got) + } + grp := GroupMessage{messageRow: messageRow{ID: 4, SenderID: 5}, GroupID: 9} + if got := grp.entity(); got.GroupID != 9 || got.Kind != entity.KindGroup { + t.Fatalf("%+v", got) + } +} diff --git a/internal/module/meta/entity/enum.go b/internal/module/meta/entity/enum.go new file mode 100644 index 0000000..a3851ec --- /dev/null +++ b/internal/module/meta/entity/enum.go @@ -0,0 +1,32 @@ +// Package entity holds the enum catalog returned by GET /v1/meta/enums. +package entity + +// Item is one closed-set member. Value is the wire JSON/TCP/SQL number or string. +type Item struct { + Value any `json:"value"` + Name string `json:"name"` + Label string `json:"label"` +} + +// Catalog is the client bootstrap payload. Keys match OpenAPI schema names in snake_case. +type Catalog struct { + Gender []Item `json:"gender"` + UserStatus []Item `json:"user_status"` + ConversationType []Item `json:"conversation_type"` + MessageKind []Item `json:"message_kind"` + MessageType []Item `json:"message_type"` + MessageStatus []Item `json:"message_status"` + RelationKind []Item `json:"relation_kind"` + FriendRequestStatus []Item `json:"friend_request_status"` + FriendSource []Item `json:"friend_source"` + ReportReason []Item `json:"report_reason"` + GroupStatus []Item `json:"group_status"` + GroupRole []Item `json:"group_role"` + MailboxEventType []Item `json:"mailbox_event_type"` + TCPCommand []Item `json:"tcp_command"` + EnvelopeCode []Item `json:"envelope_code"` + DeviceType []Item `json:"device_type"` + Language []Item `json:"language"` + UploadPurpose []Item `json:"upload_purpose"` + SystemKind []Item `json:"system_kind"` +} diff --git a/internal/module/meta/logic/enums.go b/internal/module/meta/logic/enums.go new file mode 100644 index 0000000..fcc6493 --- /dev/null +++ b/internal/module/meta/logic/enums.go @@ -0,0 +1,39 @@ +// Package logic builds GET /v1/meta/enums from entity consts and i18n labels. +package logic + +import ( + "strings" + + "spark-server/internal/module/meta/entity" + "spark-server/internal/pkg/i18n" +) + +// Catalog returns every closed set, with labels in the request language. +func Catalog(lang i18n.Lang) *entity.Catalog { + return &entity.Catalog{ + Gender: gender(lang), + UserStatus: userStatus(lang), + ConversationType: conversationType(lang), + MessageKind: messageKind(lang), + MessageType: messageType(lang), + MessageStatus: messageStatus(lang), + RelationKind: relationKind(lang), + FriendRequestStatus: friendRequestStatus(lang), + FriendSource: friendSource(lang), + ReportReason: reportReason(lang), + GroupStatus: groupStatus(lang), + GroupRole: groupRole(lang), + MailboxEventType: mailboxEventType(lang), + TCPCommand: tcpCommand(lang), + EnvelopeCode: envelopeCode(lang), + DeviceType: deviceType(lang), + Language: language(lang), + UploadPurpose: uploadPurpose(lang), + SystemKind: systemKind(lang), + } +} + +func item(lang i18n.Lang, set, name string, value any) entity.Item { + key := "enum." + set + "." + strings.NewReplacer(".", "_", "-", "_").Replace(name) + return entity.Item{Value: value, Name: name, Label: i18n.T(lang, key, nil)} +} diff --git a/internal/module/meta/logic/enums_friend.go b/internal/module/meta/logic/enums_friend.go new file mode 100644 index 0000000..992a0f3 --- /dev/null +++ b/internal/module/meta/logic/enums_friend.go @@ -0,0 +1,41 @@ +package logic + +import ( + friendentity "spark-server/internal/module/friend/entity" + "spark-server/internal/module/meta/entity" + "spark-server/internal/pkg/i18n" +) + +func relationKind(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "relation_kind", "none", int(friendentity.RelationNone)), + item(lang, "relation_kind", "friend", int(friendentity.RelationFriend)), + item(lang, "relation_kind", "block", int(friendentity.RelationBlock)), + } +} + +func friendRequestStatus(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "friend_request_status", "pending", int(friendentity.RequestPending)), + item(lang, "friend_request_status", "accepted", int(friendentity.RequestAccepted)), + item(lang, "friend_request_status", "rejected", int(friendentity.RequestRejected)), + } +} + +func friendSource(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "friend_source", "unknown", int(friendentity.SourceUnknown)), + item(lang, "friend_source", "qrcode", int(friendentity.SourceQRCode)), + item(lang, "friend_source", "email", int(friendentity.SourceEmail)), + item(lang, "friend_source", "spark_id", int(friendentity.SourceSparkID)), + } +} + +func reportReason(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "report_reason", "spam", int(friendentity.ReportSpam)), + item(lang, "report_reason", "harassment", int(friendentity.ReportHarassment)), + item(lang, "report_reason", "fake", int(friendentity.ReportFake)), + item(lang, "report_reason", "other", int(friendentity.ReportOther)), + } +} diff --git a/internal/module/meta/logic/enums_group.go b/internal/module/meta/logic/enums_group.go new file mode 100644 index 0000000..ff6d2d1 --- /dev/null +++ b/internal/module/meta/logic/enums_group.go @@ -0,0 +1,31 @@ +package logic + +import ( + groupentity "spark-server/internal/module/group/entity" + "spark-server/internal/module/meta/entity" + syncentity "spark-server/internal/module/sync/entity" + "spark-server/internal/pkg/i18n" +) + +func groupStatus(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "group_status", "active", int(groupentity.StatusActive)), + item(lang, "group_status", "dissolved", int(groupentity.StatusDissolved)), + } +} + +func groupRole(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "group_role", "owner", int(groupentity.RoleOwner)), + item(lang, "group_role", "admin", int(groupentity.RoleAdmin)), + item(lang, "group_role", "member", int(groupentity.RoleMember)), + } +} + +func mailboxEventType(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "mailbox_event_type", "missed_message", int(syncentity.EventMissedMessage)), + item(lang, "mailbox_event_type", "recall", int(syncentity.EventRecall)), + item(lang, "mailbox_event_type", "delete", int(syncentity.EventDelete)), + } +} diff --git a/internal/module/meta/logic/enums_message.go b/internal/module/meta/logic/enums_message.go new file mode 100644 index 0000000..bbe69cc --- /dev/null +++ b/internal/module/meta/logic/enums_message.go @@ -0,0 +1,36 @@ +package logic + +import ( + msgentity "spark-server/internal/module/message/entity" + "spark-server/internal/module/meta/entity" + "spark-server/internal/pkg/i18n" +) + +func messageKind(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "message_kind", "private", int(msgentity.KindPrivate)), + item(lang, "message_kind", "group", int(msgentity.KindGroup)), + } +} + +func messageType(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "message_type", "text", int(msgentity.TypeText)), + item(lang, "message_type", "image", int(msgentity.TypeImage)), + item(lang, "message_type", "voice", int(msgentity.TypeVoice)), + item(lang, "message_type", "video", int(msgentity.TypeVideo)), + item(lang, "message_type", "emoji", int(msgentity.TypeEmoji)), + item(lang, "message_type", "gif", int(msgentity.TypeGIF)), + item(lang, "message_type", "file", int(msgentity.TypeFile)), + item(lang, "message_type", "location", int(msgentity.TypeLocation)), + item(lang, "message_type", "system", int(msgentity.TypeSystem)), + } +} + +func messageStatus(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "message_status", "normal", int(msgentity.StatusNormal)), + item(lang, "message_status", "recalled", int(msgentity.StatusRecalled)), + item(lang, "message_status", "hidden", int(msgentity.StatusHidden)), + } +} diff --git a/internal/module/meta/logic/enums_test.go b/internal/module/meta/logic/enums_test.go new file mode 100644 index 0000000..8a58089 --- /dev/null +++ b/internal/module/meta/logic/enums_test.go @@ -0,0 +1,47 @@ +package logic + +import ( + "testing" + + msgentity "spark-server/internal/module/message/entity" + "spark-server/internal/module/meta/entity" + "spark-server/internal/pkg/i18n" + "spark-server/internal/transport/tcp/protocol" +) + +func TestCatalogValuesMatchEntity(t *testing.T) { + c := Catalog(i18n.ZhCN) + if len(c.Gender) != 3 || c.Gender[1].Value != 1 || c.Gender[1].Name != "male" || c.Gender[1].Label != "男" { + t.Fatalf("gender %+v", c.Gender) + } + if len(c.MessageType) != 9 || c.MessageType[0].Value != int(msgentity.TypeText) || c.MessageType[8].Value != int(msgentity.TypeSystem) { + t.Fatalf("message_type %+v", c.MessageType) + } + if c.FriendRequestStatus[0].Value != 1 || c.FriendRequestStatus[1].Value != 2 { + t.Fatalf("friend_request_status %+v", c.FriendRequestStatus) + } + if c.TCPCommand[0].Value != int(protocol.CmdAuth) || c.TCPCommand[len(c.TCPCommand)-1].Value != int(protocol.CmdDeleteMessage) { + t.Fatalf("tcp_command %+v", c.TCPCommand) + } + if c.EnvelopeCode[0].Value != "0" || c.Language[0].Value != "zh-CN" { + t.Fatalf("wire %+v %+v", c.EnvelopeCode[0], c.Language) + } + en := Catalog(i18n.En) + if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" { + t.Fatalf("en labels %+v %+v", en.Gender, en.MessageType) + } + for _, items := range [][]entity.Item{ + c.Gender, c.UserStatus, c.ConversationType, c.MessageKind, c.MessageType, c.MessageStatus, + c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole, + c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind, + } { + if len(items) == 0 { + t.Fatal("empty set") + } + for _, it := range items { + if it.Name == "" || it.Label == "" || it.Value == nil { + t.Fatalf("item %+v", it) + } + } + } +} diff --git a/internal/module/meta/logic/enums_user.go b/internal/module/meta/logic/enums_user.go new file mode 100644 index 0000000..6a2d225 --- /dev/null +++ b/internal/module/meta/logic/enums_user.go @@ -0,0 +1,30 @@ +package logic + +import ( + conventity "spark-server/internal/module/conversation/entity" + "spark-server/internal/module/meta/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/i18n" +) + +func gender(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "gender", "unknown", int(userentity.GenderUnknown)), + item(lang, "gender", "male", int(userentity.GenderMale)), + item(lang, "gender", "female", int(userentity.GenderFemale)), + } +} + +func userStatus(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "user_status", "disabled", int(userentity.StatusDisabled)), + item(lang, "user_status", "active", int(userentity.StatusActive)), + } +} + +func conversationType(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "conversation_type", "p2p", int(conventity.TypeP2P)), + item(lang, "conversation_type", "group", int(conventity.TypeGroup)), + } +} diff --git a/internal/module/meta/logic/enums_wire.go b/internal/module/meta/logic/enums_wire.go new file mode 100644 index 0000000..5c3bd89 --- /dev/null +++ b/internal/module/meta/logic/enums_wire.go @@ -0,0 +1,90 @@ +package logic + +import ( + friendentity "spark-server/internal/module/friend/entity" + groupentity "spark-server/internal/module/group/entity" + "spark-server/internal/module/meta/entity" + uploadentity "spark-server/internal/module/upload/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/transport/tcp/protocol" +) + +func tcpCommand(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "tcp_command", "auth", int(protocol.CmdAuth)), + item(lang, "tcp_command", "heartbeat", int(protocol.CmdHeartbeat)), + item(lang, "tcp_command", "generate_msg_id", int(protocol.CmdGenerateMsgID)), + item(lang, "tcp_command", "send_message", int(protocol.CmdSendMessage)), + item(lang, "tcp_command", "send_ack", int(protocol.CmdSendAck)), + item(lang, "tcp_command", "message", int(protocol.CmdMessage)), + item(lang, "tcp_command", "push_ack", int(protocol.CmdPushAck)), + item(lang, "tcp_command", "read", int(protocol.CmdReadMessage)), + item(lang, "tcp_command", "recall", int(protocol.CmdRecallMessage)), + item(lang, "tcp_command", "sync_inbox", int(protocol.CmdSyncInbox)), + item(lang, "tcp_command", "sync_inbox_finish", int(protocol.CmdSyncInboxFinish)), + item(lang, "tcp_command", "sync_messages", int(protocol.CmdSyncMessages)), + item(lang, "tcp_command", "sync_messages_finish", int(protocol.CmdSyncMessagesFinish)), + item(lang, "tcp_command", "kick", int(protocol.CmdKick)), + item(lang, "tcp_command", "delete_message", int(protocol.CmdDeleteMessage)), + } +} + +func envelopeCode(lang i18n.Lang) []entity.Item { + return []entity.Item{ + code(lang, "ok", errs.OK), + code(lang, "invalid_argument", errs.ErrInvalidArgument), + code(lang, "frame_too_large", errs.ErrFrameTooLarge), + code(lang, "invalid_frame", errs.ErrInvalidFrame), + code(lang, "invalid_proxy_header", errs.ErrProxyHeader), + code(lang, "unauthorized", errs.ErrUnauthorized), + code(lang, "not_authenticated", errs.ErrNotAuthenticated), + code(lang, "forbidden", errs.ErrForbidden), + code(lang, "not_found", errs.ErrNotFound), + code(lang, "media_expired", errs.ErrMediaExpired), + code(lang, "duplicate", errs.ErrDuplicate), + code(lang, "busy", errs.ErrBusy), + code(lang, "rate_limited", errs.ErrRateLimited), + code(lang, "outbound_dropped", errs.ErrOutboundDropped), + code(lang, "connection_closed", errs.ErrClosed), + code(lang, "internal", errs.ErrInner), + code(lang, "operation_failed", errs.ErrOperatFail), + code(lang, "clock_rewind", errs.ErrClockRewind), + code(lang, "unavailable", errs.ErrAvailable), + code(lang, "timeout", errs.ErrTimeout), + } +} + +func code(lang i18n.Lang, name string, err *errs.AppError) entity.Item { + return entity.Item{Value: err.Code, Name: name, Label: i18n.ErrorMsg(lang, err.Code)} +} + +func deviceType(lang i18n.Lang) []entity.Item { + return strSet(lang, "device_type", "ios", "android", "harmony", "windows", "macos", "web") +} + +func language(lang i18n.Lang) []entity.Item { + return strSet(lang, "language", string(i18n.ZhCN), string(i18n.En), string(i18n.ZhTW)) +} + +func uploadPurpose(lang i18n.Lang) []entity.Item { + return strSet(lang, "upload_purpose", + uploadentity.PurposeAvatar, uploadentity.PurposeGroupAvatar, uploadentity.PurposeChatImage, + uploadentity.PurposeChatVoice, uploadentity.PurposeChatVideo, uploadentity.PurposeChatGIF, uploadentity.PurposeChatFile, + ) +} + +func systemKind(lang i18n.Lang) []entity.Item { + return strSet(lang, "system_kind", + friendentity.SystemKindBecameFriends, groupentity.SystemKindCreated, groupentity.SystemKindInvite, + groupentity.SystemKindLeave, groupentity.SystemKindKick, groupentity.SystemKindDissolve, groupentity.SystemKindTransfer, + ) +} + +func strSet(lang i18n.Lang, set string, values ...string) []entity.Item { + out := make([]entity.Item, 0, len(values)) + for _, v := range values { + out = append(out, item(lang, set, v, v)) + } + return out +} diff --git a/internal/module/sync/entity/request.go b/internal/module/sync/entity/request.go index 5c7221c..aa584f5 100644 --- a/internal/module/sync/entity/request.go +++ b/internal/module/sync/entity/request.go @@ -1,11 +1,25 @@ -// Package entity holds sync request types. +// Package entity holds SYNC_INBOX request and mailbox event types. package entity +const ( + EventMissedMessage EventType = 1 + EventRecall EventType = 2 + EventDelete EventType = 3 +) + +// EventType is persisted as mailbox_events.event_type. +type EventType int16 + +func (e EventType) Valid() bool { + return e == EventMissedMessage || e == EventRecall || e == EventDelete +} + type InboxRequest struct { UserID int64 DeviceID uint64 MailboxSeq int64 DialogScore int64 + Limit int } type MessagesRequest struct { @@ -13,3 +27,10 @@ type MessagesRequest struct { LastMsgID int64 Limit int } + +type MailboxItem struct { + Seq int64 `json:"seq"` + EventType EventType `json:"event_type"` + ConversationID int64 `json:"conversation_id,omitempty"` + Payload []byte `json:"payload,omitempty"` +} diff --git a/internal/module/sync/logic/inbox_test.go b/internal/module/sync/logic/inbox_test.go new file mode 100644 index 0000000..9d4eaea --- /dev/null +++ b/internal/module/sync/logic/inbox_test.go @@ -0,0 +1,45 @@ +package logic + +import ( + "testing" + + "spark-server/internal/infrastructure/id" + inboxmodel "spark-server/internal/module/inbox/model" + "spark-server/internal/module/sync/entity" + syncmodel "spark-server/internal/module/sync/model" +) + +func TestSyncInboxIncremental(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + inbox := inboxmodel.NewMemoryStore() + box := syncmodel.NewMemoryStore(ids) + svc := NewService(inbox, box) + ctx := t.Context() + if err := inbox.TouchUnread(ctx, 2, 9, 100); err != nil { + t.Fatal(err) + } + if err := inbox.IncrUnread(ctx, 2, 9, 3); err != nil { + t.Fatal(err) + } + if err := inbox.SetPreview(ctx, 9, []byte(`{"msg_id":100}`)); err != nil { + t.Fatal(err) + } + out, err := svc.SyncInbox(ctx, entity.InboxRequest{UserID: 2, DeviceID: 7}) + if err != nil || len(out.Dialogs) != 1 || out.Dialogs[0].Unread != 3 || out.DialogScore != 100 { + t.Fatalf("inbox %+v %v", out, err) + } + again, err := svc.SyncInbox(ctx, entity.InboxRequest{UserID: 2, DeviceID: 7, DialogScore: out.DialogScore, MailboxSeq: out.MailboxSeq}) + if err != nil || len(again.Dialogs) != 0 { + t.Fatalf("no dialog delta %+v %v", again, err) + } + if err := box.Put(ctx, 2, entity.EventRecall, 9, []byte(`{"msg_id":100}`)); err != nil { + t.Fatal(err) + } + rec, err := svc.SyncInbox(ctx, entity.InboxRequest{UserID: 2, DeviceID: 7, DialogScore: out.DialogScore, MailboxSeq: out.MailboxSeq}) + if err != nil || len(rec.Mailbox) != 1 || rec.Mailbox[0].EventType != entity.EventRecall { + t.Fatalf("mailbox %+v %v", rec, err) + } +} diff --git a/internal/module/sync/logic/service.go b/internal/module/sync/logic/service.go index d29f2f2..7cb7d7d 100644 --- a/internal/module/sync/logic/service.go +++ b/internal/module/sync/logic/service.go @@ -1,13 +1,110 @@ -// Package logic is inbox/message sync use-cases. +// Package logic is SYNC_INBOX. HTTP and TCP share it. package logic import ( "context" + inboxentity "spark-server/internal/module/inbox/entity" + inboxlogic "spark-server/internal/module/inbox/logic" "spark-server/internal/module/sync/entity" + "spark-server/internal/pkg/errs" ) -type Service interface { - SyncInbox(ctx context.Context, req *entity.InboxRequest) error - SyncMessages(ctx context.Context, req *entity.MessagesRequest) error +const defaultInboxLimit = 200 + +type Mailbox interface { + Put(ctx context.Context, userID int64, eventType entity.EventType, conversationID int64, payload []byte) error + ListSince(ctx context.Context, userID int64, deviceID uint64, seq int64, limit int) ([]*entity.MailboxItem, error) + SaveCursor(ctx context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error +} + +type InboxResponse struct { + Dialogs []inboxentity.Dialog `json:"dialogs"` + Mailbox []*entity.MailboxItem `json:"mailbox"` + DialogScore int64 `json:"dialog_score"` + MailboxSeq int64 `json:"mailbox_seq"` + HasMore bool `json:"has_more"` +} + +// Service is SYNC_INBOX. HTTP and TCP share it. +type Service struct { + inbox inboxlogic.Store + box Mailbox +} + +func NewService(inbox inboxlogic.Store, box Mailbox) *Service { + return &Service{inbox: inbox, box: box} +} + +func (s *Service) ready() bool { + return s != nil && s.inbox != nil +} + +func (s *Service) SyncInbox(ctx context.Context, req entity.InboxRequest) (*InboxResponse, error) { + if !s.ready() || req.UserID <= 0 { + return nil, errs.ErrUnauthorized + } + if req.MailboxSeq < 0 || req.DialogScore < 0 { + return nil, errs.ErrInvalidArgument + } + limit := req.Limit + if limit <= 0 || limit > defaultInboxLimit { + limit = defaultInboxLimit + } + hits, err := s.inbox.ListUpdatedSince(ctx, req.UserID, req.DialogScore, limit+1) + if err != nil { + return nil, err + } + hasMore := len(hits) > limit + if hasMore { + hits = hits[:limit] + } + dialogs, err := s.inbox.LoadDialogs(ctx, req.UserID, hits) + if err != nil { + return nil, err + } + if dialogs == nil { + dialogs = []inboxentity.Dialog{} + } + nextScore := maxScore(req.DialogScore, dialogs) + mailbox, nextSeq, err := s.loadMailbox(ctx, req, limit) + if err != nil { + return nil, err + } + if s.box != nil { + _ = s.box.SaveCursor(ctx, req.UserID, req.DeviceID, nextSeq, nextScore) + } + return &InboxResponse{ + Dialogs: dialogs, Mailbox: mailbox, + DialogScore: nextScore, MailboxSeq: nextSeq, HasMore: hasMore, + }, nil +} + +func (s *Service) loadMailbox(ctx context.Context, req entity.InboxRequest, limit int) ([]*entity.MailboxItem, int64, error) { + nextSeq := req.MailboxSeq + if s.box == nil { + return []*entity.MailboxItem{}, nextSeq, nil + } + mailbox, err := s.box.ListSince(ctx, req.UserID, req.DeviceID, req.MailboxSeq, limit) + if err != nil { + return nil, 0, err + } + if mailbox == nil { + mailbox = []*entity.MailboxItem{} + } + for _, item := range mailbox { + if item != nil && item.Seq > nextSeq { + nextSeq = item.Seq + } + } + return mailbox, nextSeq, nil +} + +func maxScore(cur int64, dialogs []inboxentity.Dialog) int64 { + for i := range dialogs { + if dialogs[i].Score > cur { + cur = dialogs[i].Score + } + } + return cur } diff --git a/internal/module/sync/model/memory.go b/internal/module/sync/model/memory.go new file mode 100644 index 0000000..578040b --- /dev/null +++ b/internal/module/sync/model/memory.go @@ -0,0 +1,96 @@ +package model + +import ( + "context" + "sync" + + "spark-server/internal/module/sync/entity" +) + +type memEvent struct { + entity.MailboxItem + UserID int64 + DeviceID int64 +} + +type memCursor struct { + MailboxSeq int64 + DialogScore int64 +} + +// MemoryStore is an in-process mailbox for tests and empty-DSN bring-up. +type MemoryStore struct { + mu sync.Mutex + seq map[int64]int64 + events []memEvent + cursors map[[2]int64]memCursor + ids IDGen +} + +func NewMemoryStore(ids IDGen) *MemoryStore { + return &MemoryStore{ + seq: make(map[int64]int64), + cursors: make(map[[2]int64]memCursor), + ids: ids, + } +} + +func (s *MemoryStore) Put(_ context.Context, userID int64, eventType entity.EventType, conversationID int64, payload []byte) error { + if s == nil || userID <= 0 || eventType == 0 { + return nil + } + if s.ids != nil { + if _, err := s.ids.NextID(); err != nil { + return err + } + } + s.mu.Lock() + defer s.mu.Unlock() + s.seq[userID]++ + s.events = append(s.events, memEvent{ + UserID: userID, DeviceID: 0, + MailboxItem: entity.MailboxItem{ + Seq: s.seq[userID], EventType: eventType, ConversationID: conversationID, Payload: append([]byte(nil), payload...), + }, + }) + return nil +} + +func (s *MemoryStore) ListSince(_ context.Context, userID int64, deviceID uint64, seq int64, limit int) ([]*entity.MailboxItem, error) { + if s == nil || userID <= 0 { + return nil, nil + } + if limit <= 0 { + limit = 200 + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]*entity.MailboxItem, 0) + did := int64(deviceID) + for i := range s.events { + ev := s.events[i] + if ev.UserID != userID || ev.Seq <= seq { + continue + } + if ev.DeviceID != 0 && ev.DeviceID != did { + continue + } + item := ev.MailboxItem + item.Payload = append([]byte(nil), ev.Payload...) + out = append(out, &item) + if len(out) >= limit { + break + } + } + return out, nil +} + +func (s *MemoryStore) SaveCursor(_ context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error { + if s == nil || userID <= 0 || deviceID == 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + s.cursors[[2]int64{userID, int64(deviceID)}] = memCursor{MailboxSeq: mailboxSeq, DialogScore: dialogScore} + return nil +} diff --git a/internal/module/sync/model/store.go b/internal/module/sync/model/store.go new file mode 100644 index 0000000..d44bf28 --- /dev/null +++ b/internal/module/sync/model/store.go @@ -0,0 +1,123 @@ +package model + +import ( + "context" + "errors" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/module/sync/entity" + "spark-server/internal/pkg/errs" +) + +// Store persists mailbox_events and device_sync_cursors. +type Store struct { + gdb *gorm.DB + ids IDGen +} + +type IDGen interface { + NextID() (int64, error) +} + +func NewStore(gdb *gorm.DB, ids IDGen) *Store { + return &Store{gdb: gdb, ids: ids} +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("sync-mysql", err) +} + +func (s *Store) session(ctx context.Context) (*gorm.DB, error) { + if s == nil || s.gdb == nil { + return nil, errs.ErrAvailable + } + return s.gdb.WithContext(ctx), nil +} + +func (s *Store) Put(ctx context.Context, userID int64, eventType entity.EventType, conversationID int64, payload []byte) error { + if s == nil || s.ids == nil || userID <= 0 || eventType == 0 { + return nil + } + id, err := s.ids.NextID() + if err != nil { + return err + } + db, err := s.session(ctx) + if err != nil { + return err + } + var conv *int64 + if conversationID > 0 { + cid := conversationID + conv = &cid + } + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + var maxSeq int64 + if err := tx.Model(&MailboxEvent{}).Where("user_id = ? AND device_id = 0", userID). + Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil { + return mapDBErr(err) + } + row := MailboxEvent{ + ID: id, UserID: userID, DeviceID: 0, Seq: maxSeq + 1, + EventType: int16(eventType), ConversationID: conv, Payload: payload, CreatedAt: now, + } + return mapDBErr(tx.Create(&row).Error) + }) +} + +func (s *Store) ListSince(ctx context.Context, userID int64, deviceID uint64, seq int64, limit int) ([]*entity.MailboxItem, error) { + if s == nil || userID <= 0 { + return nil, nil + } + if limit <= 0 { + limit = 200 + } + db, err := s.session(ctx) + if err != nil { + return nil, err + } + var rows []MailboxEvent + err = db.Where("user_id = ? AND (device_id = 0 OR device_id = ?) AND seq > ?", userID, int64(deviceID), seq). + Order("seq ASC").Limit(limit).Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.MailboxItem, 0, len(rows)) + for i := range rows { + item := &entity.MailboxItem{Seq: rows[i].Seq, EventType: entity.EventType(rows[i].EventType), Payload: rows[i].Payload} + if rows[i].ConversationID != nil { + item.ConversationID = *rows[i].ConversationID + } + out = append(out, item) + } + return out, nil +} + +func (s *Store) SaveCursor(ctx context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error { + if s == nil || userID <= 0 || deviceID == 0 { + return nil + } + db, err := s.session(ctx) + if err != nil { + return err + } + row := DeviceSyncCursor{ + UserID: userID, DeviceID: int64(deviceID), + MailboxSeq: mailboxSeq, DialogScore: dialogScore, UpdatedAt: time.Now(), + } + return mapDBErr(db.Save(&row).Error) +} diff --git a/internal/module/upload/entity/purpose.go b/internal/module/upload/entity/purpose.go index ae7a48b..c30a9ab 100644 --- a/internal/module/upload/entity/purpose.go +++ b/internal/module/upload/entity/purpose.go @@ -109,11 +109,11 @@ func ValidObjectKey(key string) bool { var specs = map[string]Spec{ PurposeAvatar: {Purpose: PurposeAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs}, PurposeGroupAvatar: {Purpose: PurposeGroupAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs}, - PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs}, - PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxImage, MIMEs: gifMIMEs}, - PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs}, - PurposeChatVideo: {Purpose: PurposeChatVideo, MaxSize: maxVideo, MIMEs: videoMIMEs}, - PurposeChatFile: {Purpose: PurposeChatFile, MaxSize: maxFile, MIMEs: fileMIMEs}, + PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs}, + PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxImage, MIMEs: gifMIMEs}, + PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs}, + PurposeChatVideo: {Purpose: PurposeChatVideo, MaxSize: maxVideo, MIMEs: videoMIMEs}, + PurposeChatFile: {Purpose: PurposeChatFile, MaxSize: maxFile, MIMEs: fileMIMEs}, } var imageMIMEs = set("image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif") diff --git a/internal/module/upload/logic/credential.go b/internal/module/upload/logic/credential.go index f1a4cea..590667e 100644 --- a/internal/module/upload/logic/credential.go +++ b/internal/module/upload/logic/credential.go @@ -12,7 +12,7 @@ import ( // CreateInput requests a single-object Presigned PUT. The client does not choose the key. type CreateInput struct { - Purpose string `json:"purpose" validate:"required" label:"field.purpose" errmsg:"required:validate.upload.purpose_required"` + Purpose string `json:"purpose" validate:"required,oneof=avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file" label:"field.purpose" errmsg:"oneof:validate.upload.purpose_invalid"` Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"` Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"` GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"` diff --git a/internal/module/user/entity/avatar.go b/internal/module/user/entity/avatar.go index c06cdd4..caee2de 100644 --- a/internal/module/user/entity/avatar.go +++ b/internal/module/user/entity/avatar.go @@ -53,9 +53,19 @@ func LetterAvatarDataURI(text string) string { fill := fmt.Sprintf("rgb(%d,%d,%d)", c.R, c.G, c.B) var svg string if letter := AvatarLetter(text); letter != "" { - svg = fmt.Sprintf(`%s`, fill, letter) + svg = fmt.Sprintf( + ``+ + ``+ + `%s`, + fill, letter, + ) } else { - svg = fmt.Sprintf(``, fill) + svg = fmt.Sprintf( + ``+ + ``+ + ``, + fill, + ) } return avatarDataURIPrefix + base64.StdEncoding.EncodeToString([]byte(svg)) } diff --git a/internal/module/user/entity/user.go b/internal/module/user/entity/user.go index e719955..c7719eb 100644 --- a/internal/module/user/entity/user.go +++ b/internal/module/user/entity/user.go @@ -3,18 +3,33 @@ package entity import "time" -const StatusActive = 1 +const StatusActive Status = 1 +const StatusDisabled Status = 0 + +// Status is persisted as users.status. 1=active is the live account; 0=disabled. +type Status int + +func (s Status) Valid() bool { + return s == StatusActive || s == StatusDisabled +} const ( - GenderUnknown = 0 - GenderMale = 1 - GenderFemale = 2 + GenderUnknown Gender = 0 + GenderMale Gender = 1 + GenderFemale Gender = 2 ) -func ValidGender(g int) bool { +// Gender is persisted as users.gender. +type Gender int + +func (g Gender) Valid() bool { return g == GenderUnknown || g == GenderMale || g == GenderFemale } +func ValidGender(g int) bool { + return Gender(g).Valid() +} + // User is the account. ID is uid (Snowflake), never AUTO_INCREMENT. type User struct { ID int64 @@ -23,8 +38,8 @@ type User struct { Avatar string QRCode string Birthday time.Time - Gender int - Status int + Gender Gender + Status Status LastLoginAt time.Time LastActiveAt time.Time CreatedAt time.Time diff --git a/internal/module/user/logic/profile.go b/internal/module/user/logic/profile.go index 9d8a923..990dfc1 100644 --- a/internal/module/user/logic/profile.go +++ b/internal/module/user/logic/profile.go @@ -14,18 +14,18 @@ import ( // Profile is the public account view. type Profile struct { - UID int64 `json:"uid"` - SparkID string `json:"spark_id"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - QRCode string `json:"qrcode"` - Birthday string `json:"birthday"` - Gender int `json:"gender"` - Status int `json:"status"` - LastLoginTime int64 `json:"last_login_time"` - LastActiveTime int64 `json:"last_active_time"` - CreateTime int64 `json:"create_time"` - UpdateTime int64 `json:"update_time"` + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + QRCode string `json:"qrcode"` + Birthday string `json:"birthday"` + Gender entity.Gender `json:"gender"` + Status entity.Status `json:"status"` + LastLoginTime int64 `json:"last_login_time"` + LastActiveTime int64 `json:"last_active_time"` + CreateTime int64 `json:"create_time"` + UpdateTime int64 `json:"update_time"` } func PublicProfile(u *entity.User) *Profile { @@ -79,11 +79,11 @@ type UpdateSparkIDInput struct { // UpdateProfileInput is a partial profile PATCH. Nil fields are left unchanged. type UpdateProfileInput struct { - SparkID *string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` - Nickname *string `json:"nickname" validate:"omitempty,min=1,max=64" label:"field.nickname" errmsg:"min:validate.user.nickname_invalid;max:validate.user.nickname_invalid"` - Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar" errmsg:"max:validate.user.avatar_invalid"` - Gender *int `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"` - Birthday *string `json:"birthday" validate:"omitempty" label:"field.birthday"` + SparkID *string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` + Nickname *string `json:"nickname" validate:"omitempty,min=1,max=64" label:"field.nickname" errmsg:"min:validate.user.nickname_invalid;max:validate.user.nickname_invalid"` + Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar" errmsg:"max:validate.user.avatar_invalid"` + Gender *entity.Gender `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"` + Birthday *string `json:"birthday" validate:"omitempty" label:"field.birthday"` } func (s *Service) UpdateSparkID(ctx context.Context, userID int64, sparkID string) (*entity.User, error) { @@ -149,7 +149,7 @@ func applyProfilePatch(ctx context.Context, u *entity.User, in *UpdateProfileInp u.Avatar = avatar } if in.Gender != nil { - if !entity.ValidGender(*in.Gender) { + if !in.Gender.Valid() { return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserGenderInvalid, nil)) } u.Gender = *in.Gender diff --git a/internal/module/user/model/user.go b/internal/module/user/model/user.go index b5d7f7e..0f3349a 100644 --- a/internal/module/user/model/user.go +++ b/internal/module/user/model/user.go @@ -37,8 +37,8 @@ func (r *User) entity() *entity.User { Avatar: r.Avatar, QRCode: r.QRCode, Birthday: timeOrZero(r.Birthday), - Gender: r.Gender, - Status: r.Status, + Gender: entity.Gender(r.Gender), + Status: entity.Status(r.Status), LastLoginAt: timeOrZero(r.LastLoginAt), LastActiveAt: timeOrZero(r.LastActiveAt), CreatedAt: r.CreatedAt, @@ -53,8 +53,8 @@ func fromEntity(u *entity.User) User { Nickname: u.Nickname, Avatar: u.Avatar, QRCode: u.QRCode, - Gender: u.Gender, - Status: u.Status, + Gender: int(u.Gender), + Status: int(u.Status), CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } @@ -159,7 +159,7 @@ func (m *Repo) UpdateProfile(ctx context.Context, u *entity.User) error { "spark_id": u.SparkID, "nickname": u.Nickname, "avatar": u.Avatar, - "gender": u.Gender, + "gender": int(u.Gender), "birthday": timePtr(u.Birthday), "updated_at": u.UpdatedAt, }) diff --git a/internal/pkg/errs/apperror.go b/internal/pkg/errs/apperror.go index ee553c6..daac668 100644 --- a/internal/pkg/errs/apperror.go +++ b/internal/pkg/errs/apperror.go @@ -6,7 +6,7 @@ import ( "fmt" ) -// AppError is the process-wide error type. Transport maps Code/Msg into the {code,msg,data} envelope. +// AppError is the process-wide error type. Transport maps Code/Msg into the {code,msg,request_id,data} envelope. // InnerMsg is log-only and must not be sent to clients. type AppError struct { Code string `json:"code"` diff --git a/internal/pkg/errs/errs.go b/internal/pkg/errs/errs.go index 09052ae..128055a 100644 --- a/internal/pkg/errs/errs.go +++ b/internal/pkg/errs/errs.go @@ -1,2 +1,2 @@ -// Package errs is the shared domain error set. Transport maps these to the {code,msg,data} envelope. +// Package errs is the shared domain error set. Transport maps these to the {code,msg,request_id,data} envelope. package errs diff --git a/internal/pkg/i18n/catalog_enum.go b/internal/pkg/i18n/catalog_enum.go new file mode 100644 index 0000000..d11de98 --- /dev/null +++ b/internal/pkg/i18n/catalog_enum.go @@ -0,0 +1,49 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + "enum.gender.unknown": {ZhCN: "未知", En: "Unknown", ZhTW: "未知"}, + "enum.gender.male": {ZhCN: "男", En: "Male", ZhTW: "男"}, + "enum.gender.female": {ZhCN: "女", En: "Female", ZhTW: "女"}, + "enum.user_status.disabled": {ZhCN: "停用", En: "Disabled", ZhTW: "停用"}, + "enum.user_status.active": {ZhCN: "正常", En: "Active", ZhTW: "正常"}, + "enum.conversation_type.p2p": {ZhCN: "单聊", En: "Direct", ZhTW: "單聊"}, + "enum.conversation_type.group": {ZhCN: "群聊", En: "Group", ZhTW: "群聊"}, + "enum.message_kind.private": {ZhCN: "单聊消息", En: "Private message", ZhTW: "單聊訊息"}, + "enum.message_kind.group": {ZhCN: "群聊消息", En: "Group message", ZhTW: "群聊訊息"}, + "enum.message_type.text": {ZhCN: "文本", En: "Text", ZhTW: "文字"}, + "enum.message_type.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"}, + "enum.message_type.voice": {ZhCN: "语音", En: "Voice", ZhTW: "語音"}, + "enum.message_type.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"}, + "enum.message_type.emoji": {ZhCN: "表情", En: "Emoji", ZhTW: "表情"}, + "enum.message_type.gif": {ZhCN: "GIF", En: "GIF", ZhTW: "GIF"}, + "enum.message_type.file": {ZhCN: "文件", En: "File", ZhTW: "檔案"}, + "enum.message_type.location": {ZhCN: "位置", En: "Location", ZhTW: "位置"}, + "enum.message_type.system": {ZhCN: "系统", En: "System", ZhTW: "系統"}, + "enum.message_status.normal": {ZhCN: "正常", En: "Normal", ZhTW: "正常"}, + "enum.message_status.recalled": {ZhCN: "已撤回", En: "Recalled", ZhTW: "已撤回"}, + "enum.message_status.hidden": {ZhCN: "已隐藏", En: "Hidden", ZhTW: "已隱藏"}, + "enum.relation_kind.none": {ZhCN: "无关系", En: "None", ZhTW: "無關係"}, + "enum.relation_kind.friend": {ZhCN: "好友", En: "Friend", ZhTW: "好友"}, + "enum.relation_kind.block": {ZhCN: "已拉黑", En: "Blocked", ZhTW: "已封鎖"}, + "enum.friend_request_status.pending": {ZhCN: "待处理", En: "Pending", ZhTW: "待處理"}, + "enum.friend_request_status.accepted": {ZhCN: "已同意", En: "Accepted", ZhTW: "已同意"}, + "enum.friend_request_status.rejected": {ZhCN: "已拒绝", En: "Rejected", ZhTW: "已拒絕"}, + "enum.friend_source.unknown": {ZhCN: "未知", En: "Unknown", ZhTW: "未知"}, + "enum.friend_source.qrcode": {ZhCN: "二维码", En: "QR code", ZhTW: "二維碼"}, + "enum.friend_source.email": {ZhCN: "邮箱", En: "Email", ZhTW: "電子郵件"}, + "enum.friend_source.spark_id": {ZhCN: "Spark账号", En: "Spark ID", ZhTW: "Spark帳號"}, + "enum.report_reason.spam": {ZhCN: "垃圾信息", En: "Spam", ZhTW: "垃圾訊息"}, + "enum.report_reason.harassment": {ZhCN: "骚扰", En: "Harassment", ZhTW: "騷擾"}, + "enum.report_reason.fake": {ZhCN: "不实身份", En: "Fake identity", ZhTW: "不實身分"}, + "enum.report_reason.other": {ZhCN: "其他", En: "Other", ZhTW: "其他"}, + "enum.group_status.active": {ZhCN: "正常", En: "Active", ZhTW: "正常"}, + "enum.group_status.dissolved": {ZhCN: "已解散", En: "Dissolved", ZhTW: "已解散"}, + "enum.group_role.owner": {ZhCN: "群主", En: "Owner", ZhTW: "群主"}, + "enum.group_role.admin": {ZhCN: "管理员", En: "Admin", ZhTW: "管理員"}, + "enum.group_role.member": {ZhCN: "成员", En: "Member", ZhTW: "成員"}, + "enum.mailbox_event_type.missed_message": {ZhCN: "漏推消息", En: "Missed message", ZhTW: "漏推訊息"}, + "enum.mailbox_event_type.recall": {ZhCN: "撤回", En: "Recall", ZhTW: "撤回"}, + "enum.mailbox_event_type.delete": {ZhCN: "删除", En: "Delete", ZhTW: "刪除"}, + }) +} diff --git a/internal/pkg/i18n/catalog_enum_wire.go b/internal/pkg/i18n/catalog_enum_wire.go new file mode 100644 index 0000000..bea88ac --- /dev/null +++ b/internal/pkg/i18n/catalog_enum_wire.go @@ -0,0 +1,44 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + "enum.tcp_command.auth": {ZhCN: "认证", En: "Auth", ZhTW: "認證"}, + "enum.tcp_command.heartbeat": {ZhCN: "心跳", En: "Heartbeat", ZhTW: "心跳"}, + "enum.tcp_command.generate_msg_id": {ZhCN: "申请消息ID", En: "Generate message ID", ZhTW: "申請訊息ID"}, + "enum.tcp_command.send_message": {ZhCN: "发消息", En: "Send message", ZhTW: "發訊息"}, + "enum.tcp_command.send_ack": {ZhCN: "发送确认", En: "Send ack", ZhTW: "傳送確認"}, + "enum.tcp_command.message": {ZhCN: "下行消息", En: "Message", ZhTW: "下行訊息"}, + "enum.tcp_command.push_ack": {ZhCN: "推送确认", En: "Push ack", ZhTW: "推送確認"}, + "enum.tcp_command.read": {ZhCN: "已读", En: "Read", ZhTW: "已讀"}, + "enum.tcp_command.recall": {ZhCN: "撤回", En: "Recall", ZhTW: "撤回"}, + "enum.tcp_command.sync_inbox": {ZhCN: "同步会话", En: "Sync inbox", ZhTW: "同步會話"}, + "enum.tcp_command.sync_inbox_finish": {ZhCN: "同步会话结束", En: "Sync inbox finish", ZhTW: "同步會話結束"}, + "enum.tcp_command.sync_messages": {ZhCN: "同步消息", En: "Sync messages", ZhTW: "同步訊息"}, + "enum.tcp_command.sync_messages_finish": {ZhCN: "同步消息结束", En: "Sync messages finish", ZhTW: "同步訊息結束"}, + "enum.tcp_command.kick": {ZhCN: "踢下线", En: "Kick", ZhTW: "踢下線"}, + "enum.tcp_command.delete_message": {ZhCN: "删除消息", En: "Delete message", ZhTW: "刪除訊息"}, + "enum.device_type.ios": {ZhCN: "iOS", En: "iOS", ZhTW: "iOS"}, + "enum.device_type.android": {ZhCN: "Android", En: "Android", ZhTW: "Android"}, + "enum.device_type.harmony": {ZhCN: "鸿蒙", En: "HarmonyOS", ZhTW: "鴻蒙"}, + "enum.device_type.windows": {ZhCN: "Windows", En: "Windows", ZhTW: "Windows"}, + "enum.device_type.macos": {ZhCN: "macOS", En: "macOS", ZhTW: "macOS"}, + "enum.device_type.web": {ZhCN: "Web", En: "Web", ZhTW: "Web"}, + "enum.language.zh_CN": {ZhCN: "简体中文", En: "Simplified Chinese", ZhTW: "簡體中文"}, + "enum.language.en": {ZhCN: "English", En: "English", ZhTW: "English"}, + "enum.language.zh_TW": {ZhCN: "繁体中文", En: "Traditional Chinese", ZhTW: "繁體中文"}, + "enum.upload_purpose.avatar": {ZhCN: "头像", En: "Avatar", ZhTW: "頭像"}, + "enum.upload_purpose.group_avatar": {ZhCN: "群头像", En: "Group avatar", ZhTW: "群頭像"}, + "enum.upload_purpose.chat_image": {ZhCN: "聊天图片", En: "Chat image", ZhTW: "聊天圖片"}, + "enum.upload_purpose.chat_voice": {ZhCN: "聊天语音", En: "Chat voice", ZhTW: "聊天語音"}, + "enum.upload_purpose.chat_video": {ZhCN: "聊天视频", En: "Chat video", ZhTW: "聊天影片"}, + "enum.upload_purpose.chat_gif": {ZhCN: "聊天GIF", En: "Chat GIF", ZhTW: "聊天GIF"}, + "enum.upload_purpose.chat_file": {ZhCN: "聊天文件", En: "Chat file", ZhTW: "聊天檔案"}, + "enum.system_kind.friend_became": {ZhCN: "已成为好友", En: "Became friends", ZhTW: "已成為好友"}, + "enum.system_kind.group_created": {ZhCN: "已创建群", En: "Group created", ZhTW: "已建立群"}, + "enum.system_kind.group_invite": {ZhCN: "邀请入群", En: "Invited to group", ZhTW: "邀請入群"}, + "enum.system_kind.group_leave": {ZhCN: "退出群", En: "Left group", ZhTW: "退出群"}, + "enum.system_kind.group_kick": {ZhCN: "移出群", En: "Removed from group", ZhTW: "移出群"}, + "enum.system_kind.group_dissolve": {ZhCN: "解散群", En: "Group dissolved", ZhTW: "解散群"}, + "enum.system_kind.group_transfer": {ZhCN: "转让群主", En: "Owner transferred", ZhTW: "轉讓群主"}, + }) +} diff --git a/internal/pkg/i18n/catalog_validate_group.go b/internal/pkg/i18n/catalog_validate_group.go index fbd8bb2..2395fa0 100644 --- a/internal/pkg/i18n/catalog_validate_group.go +++ b/internal/pkg/i18n/catalog_validate_group.go @@ -35,6 +35,18 @@ func init() { KeyGroupNicknameInvalid: { ZhCN: "群昵称过长", En: "Group nickname is too long", ZhTW: "群暱稱過長", }, + KeyFieldGroupAnnouncement: { + ZhCN: "群公告", En: "Group announcement", ZhTW: "群公告", + }, + KeyFieldGroupRemark: { + ZhCN: "群备注", En: "Group remark", ZhTW: "群備註", + }, + KeyGroupAnnouncementInvalid: { + ZhCN: "群公告过长", En: "Group announcement is too long", ZhTW: "群公告過長", + }, + KeyGroupRemarkInvalid: { + ZhCN: "群备注过长", En: "Group remark is too long", ZhTW: "群備註過長", + }, KeyGroupNotMember: { ZhCN: "不是群成员", En: "Not a group member", ZhTW: "不是群成員", }, @@ -56,5 +68,11 @@ func init() { KeyGroupAlreadyMember: { ZhCN: "已经是群成员", En: "Already a group member", ZhTW: "已經是群成員", }, + KeyGroupMentionDenied: { + ZhCN: "只有群主或管理员可以@所有人", En: "Only the owner or an admin can mention everyone", ZhTW: "只有群主或管理員可以@所有人", + }, + KeyGroupMentionInvalid: { + ZhCN: "只能@群成员", En: "You can only mention group members", ZhTW: "只能@群成員", + }, }) } diff --git a/internal/pkg/i18n/catalog_validate_message.go b/internal/pkg/i18n/catalog_validate_message.go new file mode 100644 index 0000000..56748e6 --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_message.go @@ -0,0 +1,21 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldClientMsgID: { + ZhCN: "客户端消息ID", En: "Client message ID", ZhTW: "用戶端訊息ID", + }, + KeyFieldContent: { + ZhCN: "消息内容", En: "Message content", ZhTW: "訊息內容", + }, + KeyFieldConversationID: { + ZhCN: "会话", En: "Conversation", ZhTW: "會話", + }, + KeyMessageClientMsgRequired: { + ZhCN: "请填写客户端消息ID", En: "Please provide a client message ID", ZhTW: "請填寫用戶端訊息ID", + }, + KeyMessageContentRequired: { + ZhCN: "请填写消息内容", En: "Please provide message content", ZhTW: "請填寫訊息內容", + }, + }) +} diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index 1592158..adbcff0 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -18,33 +18,36 @@ const ( KeyValidateOneof = "validate.oneof" KeyValidateDefault = "validate.default" - KeyFieldPhone = "field.phone" - KeyFieldPassword = "field.password" - KeyFieldDeviceID = "field.device_id" - KeyFieldEmail = "field.email" - KeyFieldVerifyCode = "field.verify_code" - KeyFieldSparkID = "field.spark_id" - KeyFieldPlatform = "field.platform" - KeyFieldDeviceName = "field.device_name" - KeyFieldDeviceType = "field.device_type" - KeyFieldRefreshToken = "field.refresh_token" - KeyFieldNickname = "field.nickname" - KeyFieldAvatar = "field.avatar" - KeyFieldGender = "field.gender" - KeyFieldBirthday = "field.birthday" - KeyFieldUID = "field.uid" - KeyFieldQRCode = "field.qrcode" - KeyFieldReason = "field.reason" - KeyFieldRemark = "field.remark" - KeyFieldRequestID = "field.request_id" - KeyFieldReportReason = "field.report_reason" - KeyFieldReportDetail = "field.report_detail" - KeyFieldPurpose = "field.purpose" - KeyFieldMime = "field.mime" - KeyFieldSize = "field.size" - KeyFieldObjectKey = "field.object_key" - KeyFieldMsgID = "field.msg_id" - KeyFieldGroupID = "field.group_id" + KeyFieldPhone = "field.phone" + KeyFieldPassword = "field.password" + KeyFieldDeviceID = "field.device_id" + KeyFieldEmail = "field.email" + KeyFieldVerifyCode = "field.verify_code" + KeyFieldSparkID = "field.spark_id" + KeyFieldPlatform = "field.platform" + KeyFieldDeviceName = "field.device_name" + KeyFieldDeviceType = "field.device_type" + KeyFieldRefreshToken = "field.refresh_token" + KeyFieldNickname = "field.nickname" + KeyFieldAvatar = "field.avatar" + KeyFieldGender = "field.gender" + KeyFieldBirthday = "field.birthday" + KeyFieldUID = "field.uid" + KeyFieldQRCode = "field.qrcode" + KeyFieldReason = "field.reason" + KeyFieldRemark = "field.remark" + KeyFieldRequestID = "field.request_id" + KeyFieldReportReason = "field.report_reason" + KeyFieldReportDetail = "field.report_detail" + KeyFieldPurpose = "field.purpose" + KeyFieldMime = "field.mime" + KeyFieldSize = "field.size" + KeyFieldObjectKey = "field.object_key" + KeyFieldMsgID = "field.msg_id" + KeyFieldClientMsgID = "field.client_msg_id" + KeyFieldContent = "field.content" + KeyFieldConversationID = "field.conversation_id" + KeyFieldGroupID = "field.group_id" KeyLoginPhoneRequired = "validate.login.phone_required" KeyLoginPasswordRequired = "validate.login.password_required" @@ -77,37 +80,45 @@ const ( KeyFriendNotFriend = "friend.not_friend" KeyFriendRequestInvalid = "friend.request_invalid" - KeyUploadPurposeRequired = "validate.upload.purpose_required" - KeyUploadPurposeInvalid = "validate.upload.purpose_invalid" - KeyUploadMIMERequired = "validate.upload.mime_required" - KeyUploadMIMEInvalid = "validate.upload.mime_invalid" - KeyUploadSizeRequired = "validate.upload.size_required" - KeyUploadSizeInvalid = "validate.upload.size_invalid" - KeyUploadSizeExceeded = "validate.upload.size_exceeded" - KeyUploadKeyRequired = "validate.upload.key_required" - KeyUploadKeyInvalid = "validate.upload.key_invalid" - KeyUploadGroupRequired = "validate.upload.group_required" - KeyFavoriteMsgRequired = "validate.favorite.msg_required" - KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid" + KeyUploadPurposeRequired = "validate.upload.purpose_required" + KeyUploadPurposeInvalid = "validate.upload.purpose_invalid" + KeyUploadMIMERequired = "validate.upload.mime_required" + KeyUploadMIMEInvalid = "validate.upload.mime_invalid" + KeyUploadSizeRequired = "validate.upload.size_required" + KeyUploadSizeInvalid = "validate.upload.size_invalid" + KeyUploadSizeExceeded = "validate.upload.size_exceeded" + KeyUploadKeyRequired = "validate.upload.key_required" + KeyUploadKeyInvalid = "validate.upload.key_invalid" + KeyUploadGroupRequired = "validate.upload.group_required" + KeyFavoriteMsgRequired = "validate.favorite.msg_required" + KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid" + KeyMessageClientMsgRequired = "validate.message.client_msg_required" + KeyMessageContentRequired = "validate.message.content_required" - KeyFieldGroupName = "field.group_name" - KeyFieldMemberUIDs = "field.member_uids" - KeyFieldRole = "field.role" - KeyFieldGroupNickname = "field.group_nickname" - KeyGroupNameRequired = "validate.group.name_required" - KeyGroupNameInvalid = "validate.group.name_invalid" - KeyGroupMembersRequired = "validate.group.members_required" - KeyGroupMembersInvalid = "validate.group.members_invalid" - KeyGroupUIDRequired = "validate.group.uid_required" - KeyGroupRoleInvalid = "validate.group.role_invalid" - KeyGroupNicknameInvalid = "validate.group.nickname_invalid" - KeyGroupNotMember = "group.not_member" - KeyGroupNotFriend = "group.not_friend" - KeyGroupOwnerLeave = "group.owner_leave" - KeyGroupFull = "group.full" - KeyGroupDissolved = "group.dissolved" - KeyGroupRoleDenied = "group.role_denied" - KeyGroupAlreadyMember = "group.already_member" + KeyFieldGroupName = "field.group_name" + KeyFieldMemberUIDs = "field.member_uids" + KeyFieldRole = "field.role" + KeyFieldGroupNickname = "field.group_nickname" + KeyFieldGroupAnnouncement = "field.group_announcement" + KeyFieldGroupRemark = "field.group_remark" + KeyGroupNameRequired = "validate.group.name_required" + KeyGroupNameInvalid = "validate.group.name_invalid" + KeyGroupMembersRequired = "validate.group.members_required" + KeyGroupMembersInvalid = "validate.group.members_invalid" + KeyGroupUIDRequired = "validate.group.uid_required" + KeyGroupRoleInvalid = "validate.group.role_invalid" + KeyGroupNicknameInvalid = "validate.group.nickname_invalid" + KeyGroupAnnouncementInvalid = "validate.group.announcement_invalid" + KeyGroupRemarkInvalid = "validate.group.remark_invalid" + KeyGroupNotMember = "group.not_member" + KeyGroupNotFriend = "group.not_friend" + KeyGroupOwnerLeave = "group.owner_leave" + KeyGroupFull = "group.full" + KeyGroupDissolved = "group.dissolved" + KeyGroupRoleDenied = "group.role_denied" + KeyGroupAlreadyMember = "group.already_member" + KeyGroupMentionDenied = "group.mention_denied" + KeyGroupMentionInvalid = "group.mention_invalid" NotifyKickReplaced = "notify.kick.replaced" NotifyKickLogout = "notify.kick.logout" diff --git a/internal/pkg/reply/reply.go b/internal/pkg/reply/reply.go index 2bd46bf..280e0ad 100644 --- a/internal/pkg/reply/reply.go +++ b/internal/pkg/reply/reply.go @@ -9,11 +9,21 @@ import ( "spark-server/internal/pkg/reqctx" ) -// Envelope is the unified HTTP and TCP response body: {"code":"","msg":"","data":{}}. +// Envelope is the unified HTTP and TCP response body: {"code":"","msg":"","request_id":"","data":{}}. type Envelope struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data json.RawMessage `json:"data"` + Code string `json:"code"` + Msg string `json:"msg"` + RequestID string `json:"request_id"` + Data json.RawMessage `json:"data"` +} + +func Stamp(ctx context.Context, env Envelope) Envelope { + return StampID(env, reqctx.RequestID(ctx)) +} + +func StampID(env Envelope, requestID string) Envelope { + env.RequestID = requestID + return env } func OK(data any) Envelope { @@ -41,7 +51,7 @@ func FromError(err error) Envelope { } func FromContext(ctx context.Context, err error) Envelope { - return FromErrorLang(reqctx.Lang(ctx), err) + return Stamp(ctx, FromErrorLang(reqctx.Lang(ctx), err)) } func FromErrorLang(lang i18n.Lang, err error) Envelope { diff --git a/internal/pkg/reply/reply_test.go b/internal/pkg/reply/reply_test.go index 0ae23e2..dd66076 100644 --- a/internal/pkg/reply/reply_test.go +++ b/internal/pkg/reply/reply_test.go @@ -1,22 +1,27 @@ package reply import ( + "bytes" "encoding/json" "testing" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" ) func TestOKAndFailEnvelope(t *testing.T) { - raw := MustMarshal(OK(map[string]int{"n": 1})) + raw := MustMarshal(StampID(OK(map[string]int{"n": 1}), "rid-1")) var env Envelope if err := json.Unmarshal(raw, &env); err != nil { t.Fatal(err) } - if env.Code != "0" || env.Msg != "ok" || string(env.Data) != `{"n":1}` { + if env.Code != "0" || env.Msg != "ok" || env.RequestID != "rid-1" || string(env.Data) != `{"n":1}` { t.Fatalf("%+v", env) } + if !bytes.Contains(raw, []byte(`"request_id":"rid-1"`)) { + t.Fatalf("field order %s", raw) + } detail := FailData(errs.ErrAvailable.Code, "unavailable", map[string]string{"mysql": "down"}) if string(detail.Data) != `{"mysql":"down"}` { t.Fatalf("data %s", detail.Data) @@ -43,4 +48,15 @@ func TestUnmarshal(t *testing.T) { if string(env.Data) != "{}" { t.Fatalf("empty data %s", env.Data) } + if env.RequestID != "" { + t.Fatalf("missing request_id %q", env.RequestID) + } +} + +func TestFromContextStampsRequestID(t *testing.T) { + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RequestID: "rid-ctx", Language: "en"}) + env := FromContext(ctx, errs.ErrUnauthorized) + if env.RequestID != "rid-ctx" || env.Code != errs.ErrUnauthorized.Code || env.Msg != "unauthorized" { + t.Fatalf("%+v", env) + } } diff --git a/internal/pkg/reqctx/reqctx.go b/internal/pkg/reqctx/reqctx.go index df59c5f..ab6223b 100644 --- a/internal/pkg/reqctx/reqctx.go +++ b/internal/pkg/reqctx/reqctx.go @@ -15,7 +15,6 @@ type ctxKey struct{} // RequestContext is the per-request identity. Use RealIP for rate limit and audit, never 127.0.0.1 from gnet.RemoteAddr. type RequestContext struct { - TraceID string RequestID string UserID int64 DeviceID uint64 @@ -45,6 +44,14 @@ func RealIP(ctx context.Context) string { return "" } +// RequestID returns the server-generated request id stored on context. +func RequestID(ctx context.Context) string { + if rc := From(ctx); rc != nil { + return rc.RequestID + } + return "" +} + // Lang is the canonical UI language (zh-CN / en / zh-TW) for errors and notifications. func Lang(ctx context.Context) i18n.Lang { if rc := From(ctx); rc != nil { @@ -84,7 +91,7 @@ func stripHost(v string) string { return v } -// NewID returns a 16-byte hex id for trace_id / request_id. +// NewID returns a 16-byte hex id for request_id. func NewID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/internal/pkg/reqctx/reqctx_test.go b/internal/pkg/reqctx/reqctx_test.go index f74ee7a..ddd497e 100644 --- a/internal/pkg/reqctx/reqctx_test.go +++ b/internal/pkg/reqctx/reqctx_test.go @@ -27,6 +27,16 @@ func TestRealIPFromContext(t *testing.T) { } } +func TestRequestIDFromContext(t *testing.T) { + ctx := With(t.Context(), &RequestContext{RequestID: "abc"}) + if RequestID(ctx) != "abc" { + t.Fatalf("got %q", RequestID(ctx)) + } + if RequestID(t.Context()) != "" { + t.Fatal("empty ctx") + } +} + func TestLangFromContext(t *testing.T) { ctx := With(t.Context(), &RequestContext{Language: "en-US"}) if Language(ctx) != "en" { diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index e609b2c..dfc5cad 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -83,7 +83,9 @@ func authLimitPath(path string) bool { func publicPath(path string) bool { switch path { - case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": + case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/": + return true + case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": return true default: return strings.HasPrefix(path, "/docs/") diff --git a/internal/transport/http/docs.go b/internal/transport/http/docs.go index 714abba..e8322d1 100644 --- a/internal/transport/http/docs.go +++ b/internal/transport/http/docs.go @@ -10,9 +10,12 @@ func registerDocs(r *gin.Engine) { r.GET("/openapi.yaml", func(c *gin.Context) { c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpec) }) - r.GET("/docs", func(c *gin.Context) { + serve := func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", docsHTML) - }) + } + r.GET("/docs", serve) + r.GET("/docs/enums", serve) + r.GET("/docs/errors", serve) r.GET("/docs/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs") }) diff --git a/internal/transport/http/docs/index.html b/internal/transport/http/docs/index.html index 69b057f..ba66521 100644 --- a/internal/transport/http/docs/index.html +++ b/internal/transport/http/docs/index.html @@ -3,24 +3,283 @@ Spark IM API + - + -
+
+ Spark IM +
    +
  1. API
  2. +
  3. enums
  4. +
  5. errors
  6. +
+ openapi.yaml + +
+ +
+
    +
  1. enums — 类型 / 状态枚举
  2. +
  3. errors — 错误码 envelope.code
  4. +
+
+
+ +
+
+

Enums

+

与 JSON / TCP / SQL 同一套 value。name 不随语言变化。label 随右上角语言变化。数据来自 GET /v1/meta/enums。点左侧目录跳转。

+
+ +
+
+
+
+ +
+
+

错误码

+

HTTP 与 TCP Body 共用 {"code","msg","request_id","data"}。code 是字符串,只有 "0" 成功。业务失败也是 HTTP 200,看 code 不要只看状态码(/readyz 未就绪才是 503)。request_id 由服务端生成,从响应体读取。点目录可跳到对应码。

+
+ +
+ + + +
codenamemsg
+
+
+
+
+ - + diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index cac2446..f4c5853 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -3,36 +3,356 @@ info: title: Spark IM API version: 0.1.0 description: | - 本页是 **HTTP REST**(Gin `127.0.0.1:8080`)。长连接 IM 是另一套 TCP,见文末。 + 本规范描述 **HTTP REST**。即时消息长连接是另一套 **TCP Frame**(TLS + 23 字节头 + JSON Body),见文末「TCP 协议」。没有 Protobuf。 - ## 响应 + ## 环境与地址 + + 公网只开放 `443`。Nginx 按 TLS SNI 分流:HTTP 证书打到 `sparkapi.wyrs365.com`,IM 证书打到 `sparkim.wyrs365.com`。进程本身只绑本机。 + + | 环境 | 用途 | HTTP Base URL | IM 长连接 | + | --- | --- | --- | --- | + | 生产 | App / 联调 | `https://sparkapi.wyrs365.com` | `sparkim.wyrs365.com:443`(TLS,SNI=`sparkim.wyrs365.com`) | + | 本机开发 | 直连 Gin / gnet | `http://127.0.0.1:8080` | `127.0.0.1:8090`(明文,无 TLS) | + + Swagger 右上角 **Servers** 切换 HTTP 环境;Try it out 打的是 HTTP,不会建 IM 长连接。 + + 文档: + + 1. HTTP API / Swagger:`/docs` + 2. 枚举目录:`/docs/enums` + 3. 错误码:`/docs/errors` + + --- + + ## 统一响应(HTTP 与 TCP Body 同一套) + + 所有业务响应 Body 都是: ```json - {"code":"0","msg":"ok","data":{}} + {"code":"0","msg":"ok","request_id":"","data":{}} ``` - `code` 是字符串。`"0"` 成功,其它失败。失败时 `data` 为 `{}`。看 `code`,不要只看 HTTP 状态(多数接口 HTTP 200;`/readyz` 未就绪才是 503)。 + | 字段 | 类型 | 规则 | + | --- | --- | --- | + | `code` | **字符串** | `"0"` 成功;其它失败。不要用数字 `0` 比较。全表见 [错误码](/docs/errors)。 | + | `msg` | 字符串 | 给人看的文案,随请求语言变化(`zh-CN` / `en` / `zh-TW`)。成功一般为 `ok`。 | + | `request_id` | 字符串 | 服务端生成的本次请求 id。客户端不用传;从响应体读取。与 Header `X-Request-ID` 相同。 | + | `data` | object / array | 成功时为业务对象;失败时固定 `{}`。字段缺失当空对象,不要当 `null`。 | - ## 鉴权 + **判定成功只看 `code === "0"`。** 不要只看 HTTP 状态码。 - 1. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。测试号 `test01@spark.com`~`test10@spark.com`,`verify_code` 任意。 - 2. 其它接口:`Authorization: Bearer `。 - 3. 可选 `X-Device-ID` 必须等于登录时的 `device_id`,否则 `40101`。 - 4. 续期 `POST /v1/auth/token/refresh`(用 refresh,不必带 Bearer)。退出 `POST /v1/auth/logout`。 + | 场景 | HTTP 状态 | envelope | + | --- | --- | --- | + | 业务成功 / 业务失败(参数、未登录、无权限等) | **200** | `code` 为 `"0"` 或 `"40001"` / `"40101"` / `"40301"` 等 | + | `/readyz` 未就绪 | **503** | `code`=`"50301"`,`data` 仍有探活字段 | + | panic / 未捕获 | 200 | `code`=`"50001"` | - 语言:`X-Language` 优先,否则 `Accept-Language`。取值 `zh-CN`(默认)/ `en` / `zh-TW`。 + Snowflake(`uid` / `msg_id` / `conversation_id`)是 int64。Android/iOS/Java 用 `long`;JavaScript 不要用 `Number` 接,用字符串或 BigInt。 - ## TCP(不是 Protobuf) + --- - 二进制 Frame 头 23 字节(Magic `SP` + Command + SequenceID + BodyLength),**Body 是上面同一套 JSON**。没有 `.proto`,不要按 Protobuf 解包。 - 公网 TLS 在 Nginx `:443` 终结;gnet 只收本机 PROXY 明文。心跳 / AUTH / GENERATE_MSG_ID 已通;发消息与同步还在接。 + ## HTTP 请求:Header / Path / Query / Body + + ``` + METHOD {base}{path}?{query} + Header: 鉴权、语言、设备、追踪 + Body: 仅 POST / PATCH 的 JSON(GET / DELETE 无 Body) + ``` + + `Content-Type`:有 Body 时必须是 `application/json`。 + + ### Header(客户端可设) + + | Header | 放哪 | 必填 | 说明 | + | --- | --- | --- | --- | + | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums` **不要**带。 | + | `X-Device-ID` | Header | 可选 | 十进制设备 id,必须等于登录时 Body 里的 `device_id`(也等于 JWT `did`),否则 `40101`。 | + | `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 | + | `Accept-Language` | Header | 可选 | 无 `X-Language` 时使用。 | + | `X-App-Version` | Header | 可选 | 客户端版本,写入请求上下文。 | + | `Content-Type` | Header | 有 Body 时必填 | `application/json`。 | + + **不要**把 token、语言、device_id 塞进业务 JSON(登录/刷新除外:登录 Body 带 `device_id`,刷新 Body 带 `refresh_token`)。 + + 网关写入、客户端不要发:`X-Real-IP`、`X-Forwarded-For`(限流/审计用,见 C6)。 + + ### 响应 Header + + | Header | 说明 | + | --- | --- | + | `X-Request-ID` | 与响应体 `request_id` 相同,服务端生成,客户端不用传 | + | `Content-Type` | `application/json; charset=utf-8` | + + ### Path / Query / Body + + | 位置 | 放什么 | 例子 | + | --- | --- | --- | + | Path | 资源 id | `/v1/friends/{uid}` 的 `uid`;`/v1/groups/{id}` 的群号;`/v1/messages/{id}` 的 MsgId | + | Query | GET 筛选 / 分页 | `GET /v1/friends/lookup?email=`;`limit`;历史 `conversation_id`、`last_msg_id` | + | Body | POST/PATCH 的业务字段 | 登录的 `email`/`verify_code`/`device_id`;发消息的 `content` | + + ### 调用顺序 + + 1. (可选)`POST /v1/auth/email-codes` Body `{"email"}`。测试号 `test01@spark.com`~`test10@spark.com` 可跳过,登录任意 `verify_code`。 + 2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。 + 3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。 + 4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。 + 5. App 在线发消息、收推送、增量同步走 **TCP**,不要用 `POST /v1/messages` 当主路径。 + + 请求示例(生产): + + ```http + POST /v1/auth/login HTTP/1.1 + Host: sparkapi.wyrs365.com + Content-Type: application/json + X-Language: zh-CN + X-App-Version: 1.0.0 + + {"email":"test01@spark.com","verify_code":"000000","device_id":7,"device_type":"ios","device_name":"iPhone"} + ``` + + ```http + GET /v1/users/me HTTP/1.1 + Host: sparkapi.wyrs365.com + Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... + X-Device-ID: 7 + X-Language: zh-CN + ``` + + ```http + POST /v1/messages HTTP/1.1 + Host: sparkapi.wyrs365.com + Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... + Content-Type: application/json + X-Device-ID: 7 + X-Language: zh-CN + + {"peer_id":1940000000000000001,"client_msg_id":"c-1","content":{"t":1,"text":{"body":"hi"}}} + ``` + + 成功:`{"code":"0","msg":"ok","request_id":"...","data":{...}}`。失败:`{"code":"40101","msg":"未授权","request_id":"...","data":{}}`。 + + --- + + ## TCP 协议(生产 `sparkim.wyrs365.com:443`) + + 不是 WebSocket、不是 HTTP、不是 Protobuf。客户端对 `sparkim.wyrs365.com:443` 做 **TLS**(SNI 必须是该域名),握手完成后在 TLS 上写二进制 Frame。本机开发可直连 `127.0.0.1:8090` 明文。 + + 心跳 30s 一次;空闲 120s 服务端断开。Frame 最大 64KB。媒体字节走 HTTP 上传,消息里只带 object key。 + + ### 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 推送用它做 PUSH_ACK + offset 19 BodyLength uint32 Body 字节数(压缩后) + offset 23 Body bytes UTF-8 JSON;Flags bit0 则先 gzip 再当 Body + ``` + + 读包:先读 23 字节,按 `BodyLength` 再读 Body。`Magic` 不对或总长超过 64KB 应断开。 + + 压缩:Body ≥ 512B 且 gzip 更小时可设 Flags bit0。未压缩 Flags=0。解压后仍不得超过 64KB。 + + ### Command + + + + + + + + + + + + + + + + + + + +
值名方向请求 Body响应 Command / Body
1AUTH客户端 → 服务端不是 envelope:{"token","device_id","app_version","language"}同 1,envelope,data={"user_id","device_id"}
2HEARTBEAT客户端 → 服务端空 Body同 2,envelope data={}
3GENERATE_MSG_ID客户端 → 服务端空同 3,data.msg_id
4SEND_MESSAGE客户端 → 服务端不是 envelope:与 HTTP POST /v1/messages 同一 JSON5 SEND_ACK,data={"msg_id","conversation_id","client_msg_id","server_time"}
5SEND_ACK服务端 → 客户端—仅作 4 的应答
6MESSAGE服务端 → 客户端—envelope,data 为消息 Item(msg_id / content / …)
7PUSH_ACK客户端 → 服务端空;SequenceID=推送帧的 SequenceID无业务响应
9RECALL客户端 → 服务端{"msg_id"}同 9,envelope
10SYNC_INBOX客户端 → 服务端{"mailbox_seq","dialog_score","limit"},均可 0同 10,data.dialogs / mailbox / 游标
12SYNC_MESSAGES客户端 → 服务端{"conversation_id","last_msg_id","limit"}同 12,data.items / has_more
14KICK服务端 → 客户端—同设备顶号,data.reason
15DELETE客户端 → 服务端{"msg_id"}同 15
+ + 除 AUTH 外,业务命令须先 AUTH,否则 `40102`。HEARTBEAT / PUSH_ACK 可以未登录。 + + **重要:** AUTH、SEND、SYNC、撤回/删除的 **请求 Body 是业务 JSON**,不要包 `{code,msg,request_id,data}`。服务端应答和 MESSAGE 推送的 Body 才是统一 envelope。失败时 Command 仍为请求 Command(发送失败不是 SEND_ACK),`code`≠`"0"`。 + + 发送成功条件(C5):MySQL COMMIT **并且** 入队 `deliver:queue` 后才 SEND_ACK。未收到 ACK 用同一 `client_msg_id` 重试。发送端其它设备会收到 MESSAGE;本设备不回推。 + + 上线增量:先 `SYNC_INBOX`(会话列表/未读 + 撤回删除邮箱),再对脏会话 `SYNC_MESSAGES`(`last_msg_id` 之后)。成功在线 MESSAGE 不进邮箱。 + +
+ Java 示例(点击展开,生产域名) + + ```java + import javax.net.ssl.*; + import java.io.*; + import java.net.*; + import java.nio.ByteBuffer; + import java.nio.charset.StandardCharsets; + import java.util.List; + + public class SparkImClient { + static final String API = "https://sparkapi.wyrs365.com"; + static final String IM_HOST = "sparkim.wyrs365.com"; + static final int IM_PORT = 443; + static final int HDR = 23; + + public static void main(String[] args) throws Exception { + String login = httpPost(API + "/v1/auth/login", + "{\"email\":\"test01@spark.com\",\"verify_code\":\"000000\",\"device_id\":7,\"device_type\":\"android\"}"); + String token = jsonString(login, "access_token"); // 从 data.access_token 取出 + + SSLSocketFactory f = (SSLSocketFactory) SSLSocketFactory.getDefault(); + SSLSocket s = (SSLSocket) f.createSocket(); + SSLParameters p = s.getSSLParameters(); + p.setServerNames(List.of(new SNIHostName(IM_HOST))); + s.setSSLParameters(p); + s.connect(new InetSocketAddress(IM_HOST, IM_PORT), 10_000); + s.startHandshake(); + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + DataInputStream in = new DataInputStream(s.getInputStream()); + + write(out, 1, 1, "{\"token\":\"" + token + "\",\"device_id\":7,\"app_version\":\"1.0.0\",\"language\":\"zh-CN\"}"); + System.out.println("AUTH " + read(in)); + write(out, 2, 2, ""); + System.out.println("HB " + read(in)); + write(out, 4, 3, "{\"peer_id\":1,\"client_msg_id\":\"c1\",\"content\":{\"t\":1,\"text\":{\"body\":\"hi\"}}}"); + System.out.println("ACK " + read(in)); // Command=5 + } + + static void write(DataOutputStream out, int cmd, int seq, String body) throws IOException { + byte[] b = body.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocate(HDR + b.length); + buf.putShort((short) 0x5350).put((byte) 1).putShort((short) cmd).putShort((short) 0) + .putLong(0).putInt(seq).putInt(b.length).put(b); + out.write(buf.array()); + out.flush(); + } + + static String read(DataInputStream in) throws IOException { + byte[] h = in.readNBytes(HDR); + ByteBuffer buf = ByteBuffer.wrap(h); + if (buf.getShort() != 0x5350) throw new IOException("magic"); + buf.get(); + int cmd = buf.getShort() & 0xffff; + int flags = buf.getShort() & 0xffff; + buf.getLong(); + int seq = buf.getInt(); + int n = buf.getInt(); + byte[] body = in.readNBytes(n); + if ((flags & 1) != 0) { + try (java.util.zip.GZIPInputStream gz = new java.util.zip.GZIPInputStream(new ByteArrayInputStream(body))) { + body = gz.readAllBytes(); + } + } + return "cmd=" + cmd + " seq=" + seq + " " + new String(body, StandardCharsets.UTF_8); + } + + static String httpPost(String url, String json) throws IOException { + HttpURLConnection c = (HttpURLConnection) URI.create(url).toURL().openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + c.setRequestProperty("X-Language", "zh-CN"); + try (OutputStream os = c.getOutputStream()) { os.write(json.getBytes(StandardCharsets.UTF_8)); } + try (InputStream is = c.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } + } + static String jsonString(String env, String key) { + int i = env.indexOf("\"" + key + "\""); + int a = env.indexOf('"', env.indexOf(':', i) + 1) + 1; + return env.substring(a, env.indexOf('"', a)); + } + } + ``` + +
+ +
+ iOS (Swift) 示例(点击展开,生产域名) + + ```swift + import Foundation + import Network + import Compression + + let api = URL(string: "https://sparkapi.wyrs365.com")! + let imHost = NWEndpoint.Host("sparkim.wyrs365.com") + let imPort = NWEndpoint.Port(443)! + + struct Envelope: Decodable { let code: String; let msg: String; let request_id: String } + struct LoginEnv: Decodable { + struct Data: Decodable { let access_token: String } + let code: String; let data: Data + } + + func login() throws -> String { + var req = URLRequest(url: api.appendingPathComponent("/v1/auth/login")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("zh-CN", forHTTPHeaderField: "X-Language") + req.httpBody = """ + {"email":"test01@spark.com","verify_code":"000000","device_id":7,"device_type":"ios","device_name":"iPhone"} + """.data(using: .utf8) + let data = try URLSession.shared.send(req) + return try JSONDecoder().decode(LoginEnv.self, from: data).data.access_token + } + + func frame(cmd: UInt16, seq: UInt32, body: Data) -> Data { + var d = Data(capacity: 23 + body.count) + func u16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xff)) } + func u32(_ v: UInt32) { + d.append(UInt8(v >> 24)); d.append(UInt8(v >> 16 & 0xff)) + d.append(UInt8(v >> 8 & 0xff)); d.append(UInt8(v & 0xff)) + } + u16(0x5350); d.append(1); u16(cmd); u16(0) + d.append(contentsOf: [UInt8](repeating: 0, count: 8)); u32(seq); u32(UInt32(body.count)); d.append(body) + return d + } + + // TLS:NWParameters.tls,SNI 随 Host 自动带 sparkim.wyrs365.com + let conn = NWConnection(host: imHost, port: imPort, using: .tls) + conn.start(queue: .main) + // 连接 ready 后: + // let token = try login() + // let auth = #"{"token":"\#(token)","device_id":7,"app_version":"1.0.0","language":"zh-CN"}"# + // conn.send(content: frame(cmd: 1, seq: 1, body: Data(auth.utf8)), completion: .idempotent) + // 收包:先 23 字节,再 BodyLength;Flags & 1 则 gunzip。看 JSON code == "0"。 + // HEARTBEAT cmd=2;SEND_MESSAGE cmd=4,应答 cmd=5;推送 cmd=6 后发 PUSH_ACK cmd=7。 + ``` + +
+ + 本机 TCP 把 Host/Port 换成 `127.0.0.1:8090`,并用 **明文 TCP**(不要 TLS)。HTTP 仍用 `http://127.0.0.1:8080`。 + + 仓库可运行示例:`docs/clients/SparkImClient.java`、`docs/clients/SparkImClient.swift`。 servers: + - url: https://sparkapi.wyrs365.com + description: 生产 - url: http://127.0.0.1:8080 - description: loopback HTTP (not public) + description: 本机开发 security: - bearerAuth: [] tags: + - name: enums + description: "[打开枚举页](/docs/enums)" + externalDocs: + description: 打开枚举页 + url: /docs/enums + - name: errors + description: "[打开错误码页](/docs/errors)" + externalDocs: + description: 打开错误码页 + url: /docs/errors - name: system + - name: meta + description: 客户端枚举 JSON。`GET /v1/meta/enums`,无需登录。 - name: auth - name: users - name: friends @@ -105,7 +425,12 @@ paths: get: tags: [system] security: [] - summary: OpenAPI 文档页(Swagger UI) + summary: HTTP API 文档页(Swagger UI) + description: | + 文档入口: + 1. 本页 `/docs` — 接口(Servers 切换环境) + 2. `/docs/enums` — 枚举,左侧目录可跳转 + 3. `/docs/errors` — 错误码(envelope.code) operationId: docsUI responses: "200": @@ -114,6 +439,39 @@ paths: text/html: schema: type: string + /docs/enums: + get: + tags: [enums] + security: [] + summary: 枚举目录页 + description: | + 拉取 `GET /v1/meta/enums`,按集合列出 value / name / label。 + 左侧有序列表点击跳到对应集合(`#enum-{set}`,例如 `/docs/enums#enum-message_type`)。 + 错误码见 `/docs/errors`。 + operationId: docsEnums + responses: + "200": + description: HTML + content: + text/html: + schema: + type: string + /docs/errors: + get: + tags: [errors] + security: [] + summary: 错误码页 + description: | + envelope.code 全表,与 HTTP/TCP 同一套。`code` 为字符串,`"0"` 成功。 + 左侧点击跳到对应码(`#code-40101`)。数据来自 `GET /v1/meta/enums` 的 `envelope_code`。 + operationId: docsErrors + responses: + "200": + description: HTML + content: + text/html: + schema: + type: string /openapi.yaml: get: tags: [system] @@ -127,6 +485,23 @@ paths: application/yaml: schema: type: string + /v1/meta/enums: + get: + tags: [meta] + security: [] + summary: 类型 / 状态枚举目录 + description: | + 客户端接入用。无需登录。`label` 随 `X-Language` / `Accept-Language` 变化。 + `value` 与 JSON/TCP/SQL 落库值一致(数字集合为 number,字符串集合为 string)。 + `name` 是稳定英文标识,不随语言变化。人读目录:`/docs/enums`(类型/状态)、`/docs/errors`(envelope.code)。 + operationId: listEnums + responses: + "200": + description: data 为 EnumCatalog + content: + application/json: + schema: + $ref: "#/components/schemas/EnumsEnvelope" /v1/auth/email-codes: post: tags: [auth] @@ -169,7 +544,14 @@ paths: 登录写入/更新 `user_devices`(device_type 存在 `platform` 列、device_name、app_version、language、last_ip),并追加 `user_login_logs`。 返回 access_token 与 refresh_token。测试账号任意验证码即可。 `device_type` 必填:ios / android / harmony / windows / macos / web(也接受旧字段 `platform`)。 + + Header(可选):`X-Language` / `Accept-Language`、`X-App-Version`。不要带 `Authorization`。 + Body(JSON):`email`、`verify_code`、`device_id`、`device_type`、`device_name`。`device_id` 只在登录 Body,之后改走 Header `X-Device-ID` 与 TCP AUTH。 operationId: login + parameters: + - $ref: "#/components/parameters/X-Language" + - $ref: "#/components/parameters/Accept-Language" + - $ref: "#/components/parameters/X-App-Version" requestBody: required: true content: @@ -253,7 +635,12 @@ paths: get: tags: [users] summary: 当前用户资料 + description: | + Header:`Authorization: Bearer `(必填),可选 `X-Device-ID`、`X-Language`。无 Body。 operationId: getMe + parameters: + - $ref: "#/components/parameters/X-Device-ID" + - $ref: "#/components/parameters/X-Language" responses: "200": description: 统一 envelope @@ -364,7 +751,7 @@ paths: remark: 同事 responses: "200": - description: data.status 为 pending 或 accepted + description: data 见 FriendApplyData。status 为 1 待处理或 2 已同意(与 friend_requests.status 同值,不是字符串) content: application/json: schema: @@ -380,7 +767,7 @@ paths: schema: { type: integer, default: 20, maximum: 100 } responses: "200": - description: data.items + description: data.items,置顶好友排在前面;每项含 remark、blocked、pinned content: application/json: schema: @@ -420,7 +807,7 @@ paths: delete: tags: [friends] summary: 删除好友 - description: 解除双向关系,双方都不再是好友。 + description: 解除双向关系,删除双方各一条好友边。 operationId: deleteFriend parameters: - $ref: "#/components/parameters/FriendUID" @@ -451,11 +838,41 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + /v1/friends/{uid}/pin: + put: + tags: [friends] + summary: 置顶好友会话 + description: 仅写入自己这一侧的好友边 is_pinned,不影响对方。 + operationId: pinFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [friends] + summary: 取消置顶 + operationId: unpinFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" /v1/friends/{uid}/block: put: tags: [friends] summary: 拉黑 - description: 单向拉黑并解除好友关系。被拉黑一方再申请时视为用户不存在。 + description: | + 单向拉黑,写在自己这一侧的好友边上,不解除好友关系。 + 任一侧拉黑后双方都不能发消息。被拉黑一方再申请时视为用户不存在。 operationId: blockFriend parameters: - $ref: "#/components/parameters/FriendUID" @@ -557,7 +974,8 @@ paths: $ref: "#/components/schemas/Envelope" patch: tags: [groups] - summary: 修改群名称 / 头像 / 全员禁言 + summary: 修改群名称 / 头像 / 公告 / 全员禁言 + description: 仅群主和管理员。`announcement` 为空字符串表示清空公告。 operationId: updateGroup parameters: - $ref: "#/components/parameters/GroupID" @@ -626,7 +1044,10 @@ paths: /v1/groups/{id}/members/{uid}: patch: tags: [groups] - summary: 改成员角色 / 禁言 / 群昵称 + summary: 设管理员、禁言、群昵称、群备注 + description: | + `role` 仅群主可改(2=管理员 3=普通成员)。`speak_muted` 群主/管理员可对成员设置;管理员不能禁言其他管理员。 + `nickname` 是我在群里的昵称(自己可改;管理员也可改他人)。`remark` 是群备注,仅自己可改。 operationId: updateGroupMember parameters: - $ref: "#/components/parameters/GroupID" @@ -793,28 +1214,382 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + /v1/conversations: + get: + tags: [conversations] + summary: 会话列表增量(离线后上线) + description: | + 与 TCP `SYNC_INBOX` 同一 Application。`dialog_score` 是 Redis 会话 ZSET 游标(score=MsgId), + `mailbox_seq` 是撤回/删除等控制事件游标。成功在线推送的新消息不进 mailbox,正文用 + `GET /v1/messages?last_msg_id=` 或 `SYNC_MESSAGES` 按 MsgId 补齐。 + + Header:`Authorization`(必填),可选 `X-Device-ID`、`X-Language`。 + Query:`dialog_score`、`mailbox_seq`、`limit`。无 Body。 + operationId: syncInbox + parameters: + - $ref: "#/components/parameters/X-Device-ID" + - $ref: "#/components/parameters/X-Language" + - in: query + name: dialog_score + required: false + schema: + type: integer + format: int64 + default: 0 + - in: query + name: mailbox_seq + required: false + schema: + type: integer + format: int64 + default: 0 + - in: query + name: limit + required: false + schema: + type: integer + default: 200 + maximum: 200 + responses: + "200": + description: data 含 dialogs / mailbox / dialog_score / mailbox_seq / has_more + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/messages: + get: + tags: [messages] + summary: 会话历史 + description: | + `id > last_msg_id` 升序,最多 200 条,`has_more` 表示还有更新消息。 + 私聊读 `private_message`(含 `receiver_id`),群聊读 `group_message`(含 `group_id` 群号)。 + 不含当前用户已删除的消息;已撤回的消息 `status=2` 仍返回。 + 长连接优先 `SYNC_MESSAGES`。 + + Header:`Authorization`(必填),可选 `X-Device-ID`、`X-Language`。 + Query:`conversation_id`、`last_msg_id`、`limit`。无 Body。 + operationId: listMessages + parameters: + - $ref: "#/components/parameters/X-Device-ID" + - $ref: "#/components/parameters/X-Language" + - in: query + name: conversation_id + required: true + schema: + type: integer + format: int64 + - in: query + name: last_msg_id + required: false + schema: + type: integer + format: int64 + default: 0 + - in: query + name: limit + required: false + schema: + type: integer + default: 200 + maximum: 200 + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [messages] + summary: 发消息(调试/非长连接;App 在线发送用 TCP) + description: | + **客户端在线发消息走 TCP `SEND_MESSAGE`(Command=4)**,不是本接口。对方实时收消息走 TCP `MESSAGE` 推送。 + 本接口与 TCP 调用同一套 Application(落库 + deliver 入队),给调试、单测、尚未接 TCP 的页面用。HTTP 发完不会变成长连接,也收不到实时推送。 + + `conversation_id` 与 `peer_id` 至少填一个。单聊写入 `private_message`,群聊写入 `group_message`。 + 回复在 `content.reply_to`。群聊 `content.at_uids` 可@成员;`content.at_all` 仅群主/管理员可@所有人。 + 成功 data 含 `msg_id`、`conversation_id`、`client_msg_id`、`server_time`。 + ACK 仅在 MySQL COMMIT 且 deliver 入队成功后返回(与 TCP `SEND_ACK` 同一条件)。 + + Header:`Authorization: Bearer `(必填)、可选 `X-Device-ID`、`X-Language`、`X-App-Version`。 + Body(JSON):`conversation_id` 或 `peer_id`、`client_msg_id`、`content`。token / device_id / 语言不放 Body。 + operationId: sendMessage + parameters: + - $ref: "#/components/parameters/X-Device-ID" + - $ref: "#/components/parameters/X-Language" + - $ref: "#/components/parameters/X-App-Version" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendMessageRequest" + example: + peer_id: 2 + client_msg_id: "c-1" + content: + t: 1 + reply_to: 0 + text: + body: hello + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/messages/{id}/recall: + post: + tags: [messages] + summary: 撤回消息 + description: 仅发送者、2 分钟内。全员可见 `status=2`,经 deliver 下发 `RECALL_MESSAGE`。 + operationId: recallMessage + parameters: + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/messages/{id}: + delete: + tags: [messages] + summary: 删除消息(仅自己) + description: 写入 `message_deletes`,对端历史不变。其他设备经 `DELETE_MESSAGE` 同步隐藏。 + operationId: deleteMessage + parameters: + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT - description: Access JWT from /v1/auth/login. Same token is used for TCP AUTH. + description: | + HTTP Header:`Authorization: Bearer `。 + `access_token` 来自 `POST /v1/auth/login` 的 `data.access_token`。TCP AUTH Body 的 `token` 用同一值(不是 Header)。 schemas: + Gender: + type: integer + enum: [0, 1, 2] + description: users.gender。0未知 1男 2女 + UserStatus: + type: integer + enum: [0, 1] + description: users.status。0停用 1正常 + ConversationType: + type: integer + enum: [1, 2] + description: conversations.type。1单聊 2群聊 + MessageKind: + type: integer + enum: [1, 2] + description: 消息分表,与 ConversationType 同值。1=private_message 2=group_message + MessageType: + type: integer + enum: [1, 2, 3, 4, 5, 6, 7, 8, 9] + description: messages.message_type / content.t。1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统 + MessageStatus: + type: integer + enum: [1, 2, 3] + description: private_message/group_message.status。1正常 2已撤回 3审核隐藏 + RelationKind: + type: integer + enum: [0, 1, 2] + description: 查看者视角 JSON relation(不是 friend_relations.status)。0无 1好友 2已拉黑 + FriendRequestStatus: + type: integer + enum: [1, 2, 3] + description: friend_requests.status,申请结果 status 同此。1待处理 2已同意 3已拒绝 + FriendSource: + type: integer + enum: [0, 1, 2, 3] + description: 好友添加来源。0未知 1二维码 2邮箱 3spark_id + ReportReason: + type: integer + enum: [1, 2, 3, 4] + description: friend_reports.reason。1垃圾 2骚扰 3不实身份 4其他 + GroupStatus: + type: integer + enum: [1, 2] + description: groups.status。1正常 2已解散 + GroupRole: + type: integer + enum: [1, 2, 3] + description: group_members.role。1群主 2管理员 3成员。PATCH 成员只能设 2 或 3 + MailboxEventType: + type: integer + format: int16 + enum: [1, 2, 3] + description: mailbox_events.event_type。1漏推消息 2撤回 3删除 + DeviceType: + type: string + enum: [ios, android, harmony, windows, macos, web] + Language: + type: string + enum: [zh-CN, en, zh-TW] + description: 缺省 zh-CN + UploadPurpose: + type: string + enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file] + SystemKind: + type: string + enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer] + description: content.system.kind,服务端系统消息 + TcpCommand: + type: integer + enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + description: | + Frame.Command:1 AUTH 2 HEARTBEAT 3 GENERATE_MSG_ID 4 SEND_MESSAGE 5 SEND_ACK + 6 MESSAGE 7 PUSH_ACK 8 READ 9 RECALL 10 SYNC_INBOX 11 SYNC_INBOX_FINISH + 12 SYNC_MESSAGES 13 SYNC_MESSAGES_FINISH 14 KICK 15 DELETE_MESSAGE + EnvelopeCode: + type: string + enum: ["0", "40001", "40002", "40003", "40004", "40101", "40102", "40301", "40401", "40402", "40901", "42901", "42902", "42903", "49901", "50001", "50002", "50003", "50301", "50401"] + description: envelope.code。成功为 "0" Envelope: type: object - required: [code, msg, data] + required: [code, msg, request_id, data] + description: | + HTTP 与 TCP 应答 Body 的统一信封。业务成败看 `code` 字符串,不看 HTTP 状态(除 `/readyz` 503)。 + 失败时 `data` 为 `{}`。成功时 `data` 为对象或列表,不会是 `null`。 + `request_id` 由服务端生成,客户端不用传;与 Header `X-Request-ID` 相同。 properties: code: - type: string - description: 业务码,成功为 "0" + allOf: + - $ref: "#/components/schemas/EnvelopeCode" + description: 字符串。`"0"` 成功,其它失败。不要用 JSON number。 example: "0" msg: type: string + description: 展示文案,随 `X-Language` / `Accept-Language` / TCP AUTH `language` 变化。 example: ok + request_id: + type: string + description: 服务端生成的本次请求 id。客户端不用传,从响应体读取。 + example: a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8 data: type: object additionalProperties: true + description: 成功为业务对象;失败为 {} + default: {} + EnumsEnvelope: + type: object + required: [code, msg, request_id, data] + properties: + code: + $ref: "#/components/schemas/EnvelopeCode" + msg: + type: string + request_id: + type: string + data: + $ref: "#/components/schemas/EnumCatalog" + EnumItem: + type: object + required: [value, name, label] + properties: + value: + description: 与 JSON/TCP/SQL 一致。数字集合为 integer,字符串集合为 string。 + oneOf: + - type: integer + - type: string + name: + type: string + description: 稳定英文标识,不随语言变化 + label: + type: string + description: 当前请求语言下的展示文案 + EnumCatalog: + type: object + properties: + gender: + type: array + items: + $ref: "#/components/schemas/EnumItem" + user_status: + type: array + items: + $ref: "#/components/schemas/EnumItem" + conversation_type: + type: array + items: + $ref: "#/components/schemas/EnumItem" + message_kind: + type: array + items: + $ref: "#/components/schemas/EnumItem" + message_type: + type: array + items: + $ref: "#/components/schemas/EnumItem" + message_status: + type: array + items: + $ref: "#/components/schemas/EnumItem" + relation_kind: + type: array + items: + $ref: "#/components/schemas/EnumItem" + friend_request_status: + type: array + items: + $ref: "#/components/schemas/EnumItem" + friend_source: + type: array + items: + $ref: "#/components/schemas/EnumItem" + report_reason: + type: array + items: + $ref: "#/components/schemas/EnumItem" + group_status: + type: array + items: + $ref: "#/components/schemas/EnumItem" + group_role: + type: array + items: + $ref: "#/components/schemas/EnumItem" + mailbox_event_type: + type: array + items: + $ref: "#/components/schemas/EnumItem" + tcp_command: + type: array + items: + $ref: "#/components/schemas/EnumItem" + envelope_code: + type: array + items: + $ref: "#/components/schemas/EnumItem" + device_type: + type: array + items: + $ref: "#/components/schemas/EnumItem" + language: + type: array + items: + $ref: "#/components/schemas/EnumItem" + upload_purpose: + type: array + items: + $ref: "#/components/schemas/EnumItem" + system_kind: + type: array + items: + $ref: "#/components/schemas/EnumItem" SendEmailCodeRequest: type: object required: [email] @@ -837,12 +1612,11 @@ components: format: int64 minimum: 1 device_type: - type: string - enum: [ios, android, harmony, windows, macos, web] + $ref: "#/components/schemas/DeviceType" platform: - type: string + allOf: + - $ref: "#/components/schemas/DeviceType" description: device_type 的兼容字段 - enum: [ios, android, harmony, windows, macos, web] device_name: type: string maxLength: 128 @@ -896,8 +1670,7 @@ components: type: string maxLength: 512 gender: - type: integer - enum: [0, 1, 2] + $ref: "#/components/schemas/Gender" birthday: type: string description: YYYY-MM-DD;空字符串表示清除 @@ -922,6 +1695,20 @@ components: remark: type: string maxLength: 64 + FriendApplyData: + type: object + required: [status, uid] + properties: + status: + $ref: "#/components/schemas/FriendRequestStatus" + request_id: + type: integer + format: int64 + description: status=1 时返回,待处理申请 id + uid: + type: integer + format: int64 + description: 对方 Snowflake uid FriendAcceptRequest: type: object required: [request_id] @@ -943,9 +1730,7 @@ components: required: [reason] properties: reason: - type: integer - enum: [1, 2, 3, 4] - description: 1=垃圾 2=骚扰 3=不实身份 4=其他 + $ref: "#/components/schemas/ReportReason" detail: type: string maxLength: 512 @@ -954,8 +1739,7 @@ components: required: [purpose, mime, size] properties: purpose: - type: string - enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file] + $ref: "#/components/schemas/UploadPurpose" mime: type: string example: image/jpeg @@ -975,6 +1759,56 @@ components: object_key: type: string maxLength: 512 + SendMessageRequest: + type: object + required: [client_msg_id, content] + properties: + conversation_id: + type: integer + format: int64 + peer_id: + type: integer + format: int64 + description: 单聊对方 uid;与 conversation_id 至少填一个 + client_msg_id: + type: string + maxLength: 64 + msg_id: + type: integer + format: int64 + description: 可选,须为 ±5 分钟内的 Snowflake + content: + type: object + required: [t] + properties: + t: + $ref: "#/components/schemas/MessageType" + reply_to: + type: integer + format: int64 + description: 被回复消息 MsgId + text: + type: object + properties: + body: + type: string + at_all: + type: boolean + description: "@所有人,仅群主/管理员" + at_uids: + type: array + maxItems: 50 + items: + type: integer + format: int64 + description: "@的群成员 uid" + system: + type: object + properties: + kind: + $ref: "#/components/schemas/SystemKind" + body: + type: string CreateFavoriteRequest: type: object required: [msg_id] @@ -1009,6 +1843,9 @@ components: type: string muted_all: type: boolean + announcement: + type: string + maxLength: 1024 InviteGroupMembersRequest: type: object required: [uids] @@ -1023,14 +1860,18 @@ components: type: object properties: role: - type: integer - enum: [2, 3] - description: 2=管理员 3=普通成员;群主只能通过转让变更 + allOf: + - $ref: "#/components/schemas/GroupRole" + description: 仅 2=管理员 3=普通成员;群主只能通过转让变更 speak_muted: type: boolean nickname: type: string maxLength: 32 + remark: + type: string + maxLength: 32 + description: 对该群的备注,仅自己可改 TransferGroupRequest: type: object required: [uid] @@ -1040,12 +1881,14 @@ components: format: int64 LoginEnvelope: type: object - required: [code, msg, data] + required: [code, msg, request_id, data] properties: code: type: string msg: type: string + request_id: + type: string data: $ref: "#/components/schemas/LoginData" AuthData: @@ -1067,9 +1910,7 @@ components: X-Request-ID: schema: type: string - X-Trace-ID: - schema: - type: string + description: 服务端生成的本次请求 id。与响应体 `request_id` 相同。客户端不用传。 X-Device-ID: schema: type: string @@ -1079,14 +1920,12 @@ components: type: string Accept-Language: schema: - type: string - enum: [zh-CN, en, zh-TW] + $ref: "#/components/schemas/Language" example: zh-CN description: 用户语言。也可用 X-Language。缺省 zh-CN。 X-Language: schema: - type: string - enum: [zh-CN, en, zh-TW] + $ref: "#/components/schemas/Language" description: 覆盖 Accept-Language 的简洁语言标签。 X-Real-IP: schema: @@ -1099,35 +1938,38 @@ components: required: true schema: type: string - description: "Bearer " + example: Bearer eyJhbGciOiJIUzI1NiJ9... + description: Header。`Bearer `。登录/发码/刷新/探活/文档/枚举不要带。 X-Device-ID: in: header name: X-Device-ID required: false schema: type: string + example: "7" + description: Header。十进制 device_id,须等于登录 Body 的 device_id / JWT did。不放 Body。 X-App-Version: in: header name: X-App-Version required: false schema: type: string + example: 1.0.0 + description: Header。客户端版本。登录时也可在 Body 带 app_version;此后用本 Header。 Accept-Language: in: header name: Accept-Language required: false schema: - type: string - enum: [zh-CN, en, zh-TW] - description: 用户语言。缺省 zh-CN。 + $ref: "#/components/schemas/Language" + description: Header。用户语言。缺省 zh-CN。无 X-Language 时生效。 X-Language: in: header name: X-Language required: false schema: - type: string - enum: [zh-CN, en, zh-TW] - description: 覆盖 Accept-Language。 + $ref: "#/components/schemas/Language" + description: Header。覆盖 Accept-Language。决定 envelope.msg 语言。不放 Body。 FriendUID: in: path name: uid @@ -1152,3 +1994,11 @@ components: type: integer format: int64 description: 自增群号(不是 conversation_id)。 + MessageID: + in: path + name: id + required: true + schema: + type: integer + format: int64 + description: Snowflake MsgId。 diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go index b2ef2e2..32cdd6a 100644 --- a/internal/transport/http/friend_test.go +++ b/internal/transport/http/friend_test.go @@ -16,6 +16,7 @@ import ( convlogic "spark-server/internal/module/conversation/logic" convmodel "spark-server/internal/module/conversation/model" delivermodel "spark-server/internal/module/deliver/model" + friendentity "spark-server/internal/module/friend/entity" friendlogic "spark-server/internal/module/friend/logic" friendmodel "spark-server/internal/module/friend/model" msgmodel "spark-server/internal/module/message/model" @@ -107,7 +108,7 @@ func TestFriendApplyAcceptHTTP(t *testing.T) { t.Fatalf("apply %+v", apply) } var applied friendlogic.ApplyResult - if err := json.Unmarshal(apply.Data, &applied); err != nil || applied.Status != "pending" || applied.RequestID == 0 { + if err := json.Unmarshal(apply.Data, &applied); err != nil || applied.Status != friendentity.RequestPending || applied.RequestID == 0 { t.Fatalf("apply data %+v %v", applied, err) } again := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com","reason":"again"}`) @@ -133,4 +134,20 @@ func TestFriendApplyAcceptHTTP(t *testing.T) { if list.Code != "0" || !strings.Contains(string(list.Data), fmt.Sprintf(`"uid":%d`, a.UserID)) { t.Fatalf("list %+v", list) } + pin := authJSON(t, h, &a, http.MethodPut, fmt.Sprintf("/v1/friends/%d/pin", b.UserID), "") + if pin.Code != "0" { + t.Fatalf("pin %+v", pin) + } + got = authJSON(t, h, &a, http.MethodGet, fmt.Sprintf("/v1/friends/%d", b.UserID), "") + if got.Code != "0" || !strings.Contains(string(got.Data), `"pinned":true`) { + t.Fatalf("pinned get %+v", got) + } + block := authJSON(t, h, &a, http.MethodPut, fmt.Sprintf("/v1/friends/%d/block", b.UserID), "") + if block.Code != "0" { + t.Fatalf("block %+v", block) + } + got = authJSON(t, h, &a, http.MethodGet, fmt.Sprintf("/v1/friends/%d", b.UserID), "") + if got.Code != "0" || !strings.Contains(string(got.Data), `"blocked":true`) { + t.Fatalf("blocked get %+v", got) + } } diff --git a/internal/transport/http/group_test.go b/internal/transport/http/group_test.go index a13be05..c8015c0 100644 --- a/internal/transport/http/group_test.go +++ b/internal/transport/http/group_test.go @@ -82,4 +82,28 @@ func TestGroupCreateHTTP(t *testing.T) { if members.Code != "0" { t.Fatalf("members %+v", members) } + ann := authJSON(t, h, &a, http.MethodPatch, fmt.Sprintf("/v1/groups/%d", g.ID), `{"announcement":"今晚九点","name":"周末局"}`) + if ann.Code != "0" { + t.Fatalf("announce %+v", ann) + } + denied := authJSON(t, h, &b, http.MethodPatch, fmt.Sprintf("/v1/groups/%d", g.ID), `{"announcement":"x"}`) + if denied.Code == "0" { + t.Fatal("member announcement") + } + role := authJSON(t, h, &a, http.MethodPatch, fmt.Sprintf("/v1/groups/%d/members/%d", g.ID, b.UserID), `{"role":2}`) + if role.Code != "0" { + t.Fatalf("admin %+v", role) + } + self := authJSON(t, h, &b, http.MethodPatch, fmt.Sprintf("/v1/groups/%d/members/%d", g.ID, b.UserID), `{"nickname":"球","remark":"球友群"}`) + if self.Code != "0" { + t.Fatalf("self card %+v", self) + } + card := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/groups/%d", g.ID), "") + if card.Code != "0" { + t.Fatalf("get card %+v", card) + } + var view grouplogic.View + if err := json.Unmarshal(card.Data, &view); err != nil || view.Announcement != "今晚九点" || view.Remark != "球友群" || view.GroupNickname != "球" || view.Role != 2 { + t.Fatalf("card %+v %v", view, err) + } } diff --git a/internal/transport/http/handler/conversation/handler.go b/internal/transport/http/handler/conversation/handler.go new file mode 100644 index 0000000..3f28b82 --- /dev/null +++ b/internal/transport/http/handler/conversation/handler.go @@ -0,0 +1,42 @@ +// Package convhandler is the conversation list / inbox sync HTTP controller. +package convhandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + syncentity "spark-server/internal/module/sync/entity" + synclogic "spark-server/internal/module/sync/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the conversation list / inbox sync controller. +type Handler struct { + sync *synclogic.Service +} + +func New(sync *synclogic.Service) *Handler { + return &Handler{sync: sync} +} + +func (h *Handler) Inbox(c *gin.Context) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return + } + score, _ := strconv.ParseInt(c.Query("dialog_score"), 10, 64) + seq, _ := strconv.ParseInt(c.Query("mailbox_seq"), 10, 64) + limit, _ := strconv.Atoi(c.Query("limit")) + out, err := h.sync.SyncInbox(c.Request.Context(), syncentity.InboxRequest{ + UserID: rc.UserID, DeviceID: rc.DeviceID, DialogScore: score, MailboxSeq: seq, Limit: limit, + }) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} diff --git a/internal/transport/http/handler/friend/member.go b/internal/transport/http/handler/friend/member.go index 44e7e16..5d1f2cd 100644 --- a/internal/transport/http/handler/friend/member.go +++ b/internal/transport/http/handler/friend/member.go @@ -42,6 +42,40 @@ func (h *Handler) Delete(c *gin.Context) { respond.JSON(c, gin.H{}) } +func (h *Handler) Pin(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + friendID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Pin(c.Request.Context(), uid, friendID); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Unpin(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + friendID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Unpin(c.Request.Context(), uid, friendID); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + func (h *Handler) Remark(c *gin.Context) { uid, ok := currentUID(c) if !ok { diff --git a/internal/transport/http/handler/message/handler.go b/internal/transport/http/handler/message/handler.go new file mode 100644 index 0000000..1087c97 --- /dev/null +++ b/internal/transport/http/handler/message/handler.go @@ -0,0 +1,112 @@ +package msghandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + msglogic "spark-server/internal/module/message/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the messages HTTP controller. +type Handler struct { + msgs *msglogic.Service +} + +func New(msgs *msglogic.Service) *Handler { + return &Handler{msgs: msgs} +} + +func (h *Handler) Send(c *gin.Context) { + uid, did, ok := currentUser(c) + if !ok { + return + } + var in msglogic.SendInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.msgs.SendMessage(c.Request.Context(), uid, did, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) History(c *gin.Context) { + uid, _, ok := currentUser(c) + if !ok { + return + } + convID, err := strconv.ParseInt(c.Query("conversation_id"), 10, 64) + if err != nil || convID <= 0 { + respond.JSONError(c, errs.ErrInvalidArgument) + return + } + lastID, _ := strconv.ParseInt(c.Query("last_msg_id"), 10, 64) + limit, _ := strconv.Atoi(c.Query("limit")) + out, err := h.msgs.GetHistory(c.Request.Context(), uid, msglogic.HistoryRequest{ + ConversationID: convID, LastMsgID: lastID, Limit: limit, + }) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Recall(c *gin.Context) { + uid, did, ok := currentUser(c) + if !ok { + return + } + id, err := pathMsgID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.msgs.RecallMessage(c.Request.Context(), uid, did, id); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Delete(c *gin.Context) { + uid, did, ok := currentUser(c) + if !ok { + return + } + id, err := pathMsgID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.msgs.DeleteMessage(c.Request.Context(), uid, did, id); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func pathMsgID(c *gin.Context) (int64, error) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + return 0, errs.ErrInvalidArgument + } + return id, nil +} + +func currentUser(c *gin.Context) (int64, uint64, bool) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return 0, 0, false + } + return rc.UserID, rc.DeviceID, true +} diff --git a/internal/transport/http/handler/meta/handler.go b/internal/transport/http/handler/meta/handler.go new file mode 100644 index 0000000..03517c0 --- /dev/null +++ b/internal/transport/http/handler/meta/handler.go @@ -0,0 +1,20 @@ +package metahandler + +import ( + "github.com/gin-gonic/gin" + + metalogic "spark-server/internal/module/meta/logic" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the meta HTTP controller. +type Handler struct{} + +func New() *Handler { + return &Handler{} +} + +func (h *Handler) Enums(c *gin.Context) { + respond.JSON(c, metalogic.Catalog(reqctx.Lang(c.Request.Context()))) +} diff --git a/internal/transport/http/inbox_test.go b/internal/transport/http/inbox_test.go new file mode 100644 index 0000000..c48083d --- /dev/null +++ b/internal/transport/http/inbox_test.go @@ -0,0 +1,129 @@ +package httpx + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "go.uber.org/zap" + + "spark-server/internal/infrastructure/id" + authlogic "spark-server/internal/module/auth/logic" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + deliverlogic "spark-server/internal/module/deliver/logic" + delivermodel "spark-server/internal/module/deliver/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + inboxmodel "spark-server/internal/module/inbox/model" + msglogic "spark-server/internal/module/message/logic" + msgmodel "spark-server/internal/module/message/model" + synclogic "spark-server/internal/module/sync/logic" + syncmodel "spark-server/internal/module/sync/model" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" +) + +func TestInboxIncrementalHTTP(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + chats := convmodel.NewMemoryP2P() + p2p := convlogic.NewP2PService(chats, ids) + q := delivermodel.NewMemoryQueue() + store := msgmodel.NewMemoryRepo() + friends := friendlogic.NewService( + friendmodel.NewMemoryRepo(), users, ids, + friendlogic.NewGreeter(p2p, store, ids, q), + ) + msgs := msglogic.NewService(store, chats, p2p, friends, nil, ids, q) + inbox := inboxmodel.NewMemoryStore() + box := syncmodel.NewMemoryStore(ids) + syncSvc := synclogic.NewService(inbox, box) + h := NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, Friends: friends, Messages: msgs, Sync: syncSvc, + }) + a := loginUser(t, h, "test01@spark.com", 1) + b := loginUser(t, h, "test02@spark.com", 2) + apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com"}`) + if apply.Code != "0" { + t.Fatalf("apply %+v", apply) + } + var applied friendlogic.ApplyResult + if err := json.Unmarshal(apply.Data, &applied); err != nil { + t.Fatal(err) + } + acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d}`, applied.RequestID)) + if acc.Code != "0" { + t.Fatalf("accept %+v", acc) + } + sent := authJSON(t, h, &a, http.MethodPost, "/v1/messages", + fmt.Sprintf(`{"peer_id":%d,"client_msg_id":"c1","content":{"t":1,"text":{"body":"hi"}}}`, b.UserID)) + if sent.Code != "0" { + t.Fatalf("send %+v", sent) + } + var ack msglogic.SendResponse + if err := json.Unmarshal(sent.Data, &ack); err != nil { + t.Fatal(err) + } + fanout := deliverlogic.InboxFanout(nil, inbox, box) + drainDeliver(t, q, fanout) + list := authJSON(t, h, &b, http.MethodGet, "/v1/conversations?dialog_score=0&mailbox_seq=0", "") + if list.Code != "0" { + t.Fatalf("inbox %+v", list) + } + var delta synclogic.InboxResponse + if err := json.Unmarshal(list.Data, &delta); err != nil || len(delta.Dialogs) == 0 { + t.Fatalf("delta %+v %v", delta, err) + } + hist := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/messages?conversation_id=%d&last_msg_id=0", ack.ConversationID), "") + if hist.Code != "0" { + t.Fatalf("history %+v", hist) + } + var page msglogic.HistoryResponse + if err := json.Unmarshal(hist.Data, &page); err != nil || len(page.Items) == 0 { + t.Fatalf("history data %+v %v", page, err) + } + lastID := page.Items[len(page.Items)-1].MsgID + caughtHist := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/messages?conversation_id=%d&last_msg_id=%d", ack.ConversationID, lastID), "") + if caughtHist.Code != "0" { + t.Fatalf("caught hist %+v", caughtHist) + } + var emptyHist msglogic.HistoryResponse + if err := json.Unmarshal(caughtHist.Data, &emptyHist); err != nil || len(emptyHist.Items) != 0 { + t.Fatalf("want no more messages %+v %v", emptyHist, err) + } + caught := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/conversations?dialog_score=%d&mailbox_seq=%d", delta.DialogScore, delta.MailboxSeq), "") + if caught.Code != "0" { + t.Fatalf("caught %+v", caught) + } + var empty synclogic.InboxResponse + if err := json.Unmarshal(caught.Data, &empty); err != nil || len(empty.Dialogs) != 0 { + t.Fatalf("want empty delta %+v %v", empty, err) + } +} + +func drainDeliver(t *testing.T, q *delivermodel.MemoryQueue, handle deliverlogic.Handler) { + t.Helper() + ctx := t.Context() + for i := 0; i < 16; i++ { + job, err := q.Take(ctx, 20*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if job == nil { + return + } + if err := handle(ctx, job); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/transport/http/message_test.go b/internal/transport/http/message_test.go new file mode 100644 index 0000000..355fe77 --- /dev/null +++ b/internal/transport/http/message_test.go @@ -0,0 +1,97 @@ +package httpx + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "go.uber.org/zap" + + "spark-server/internal/infrastructure/id" + authlogic "spark-server/internal/module/auth/logic" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + delivermodel "spark-server/internal/module/deliver/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + msglogic "spark-server/internal/module/message/logic" + msgmodel "spark-server/internal/module/message/model" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" +) + +func testMessageEngine(t *testing.T) http.Handler { + t.Helper() + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + chats := convmodel.NewMemoryP2P() + p2p := convlogic.NewP2PService(chats, ids) + q := delivermodel.NewMemoryQueue() + store := msgmodel.NewMemoryRepo() + friends := friendlogic.NewService( + friendmodel.NewMemoryRepo(), users, ids, + friendlogic.NewGreeter(p2p, store, ids, q), + ) + msgs := msglogic.NewService(store, chats, p2p, friends, nil, ids, q) + return NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, Friends: friends, Messages: msgs, + }) +} + +func TestMessageSendHistoryRecallDeleteHTTP(t *testing.T) { + h := testMessageEngine(t) + a := loginUser(t, h, "test01@spark.com", 1) + b := loginUser(t, h, "test02@spark.com", 2) + apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com","reason":"hi"}`) + if apply.Code != "0" { + t.Fatalf("apply %+v", apply) + } + var applied friendlogic.ApplyResult + if err := json.Unmarshal(apply.Data, &applied); err != nil { + t.Fatal(err) + } + acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d}`, applied.RequestID)) + if acc.Code != "0" { + t.Fatalf("accept %+v", acc) + } + + sent := authJSON(t, h, &a, http.MethodPost, "/v1/messages", + fmt.Sprintf(`{"peer_id":%d,"client_msg_id":"c1","content":{"t":1,"text":{"body":"hi"}}}`, b.UserID)) + if sent.Code != "0" { + t.Fatalf("send %+v", sent) + } + var ack msglogic.SendResponse + if err := json.Unmarshal(sent.Data, &ack); err != nil || ack.MsgID == 0 || ack.ConversationID == 0 { + t.Fatalf("ack %+v %v", ack, err) + } + replyBody := fmt.Sprintf(`{"peer_id":%d,"client_msg_id":"c2","content":{"t":1,"reply_to":%d,"text":{"body":"re"}}}`, a.UserID, ack.MsgID) + re := authJSON(t, h, &b, http.MethodPost, "/v1/messages", replyBody) + if re.Code != "0" { + t.Fatalf("reply %+v", re) + } + var reAck msglogic.SendResponse + if err := json.Unmarshal(re.Data, &reAck); err != nil { + t.Fatal(err) + } + + hist := authJSON(t, h, &a, http.MethodGet, fmt.Sprintf("/v1/messages?conversation_id=%d", ack.ConversationID), "") + if hist.Code != "0" { + t.Fatalf("history %+v", hist) + } + rec := authJSON(t, h, &a, http.MethodPost, fmt.Sprintf("/v1/messages/%d/recall", ack.MsgID), "") + if rec.Code != "0" { + t.Fatalf("recall %+v", rec) + } + del := authJSON(t, h, &a, http.MethodDelete, fmt.Sprintf("/v1/messages/%d", reAck.MsgID), "") + if del.Code != "0" { + t.Fatalf("delete %+v", del) + } +} diff --git a/internal/transport/http/meta_test.go b/internal/transport/http/meta_test.go new file mode 100644 index 0000000..ba30ebd --- /dev/null +++ b/internal/transport/http/meta_test.go @@ -0,0 +1,50 @@ +package httpx + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "spark-server/internal/module/meta/entity" + "spark-server/internal/pkg/reply" +) + +func TestMetaEnumsPublic(t *testing.T) { + r := testLoginEngine(t) + req := httptest.NewRequest(http.MethodGet, "/v1/meta/enums", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if w.Code != http.StatusOK || env.Code != "0" { + t.Fatalf("%d %+v", w.Code, env) + } + var cat entity.Catalog + if err := json.Unmarshal(env.Data, &cat); err != nil { + t.Fatal(err) + } + if len(cat.Gender) != 3 || cat.Gender[1].Name != "male" || cat.Gender[1].Label != "男" { + t.Fatalf("gender %+v", cat.Gender) + } + if len(cat.MessageType) != 9 || len(cat.TCPCommand) != 15 || cat.Language[0].Value != "zh-CN" { + t.Fatalf("catalog %+v", cat) + } + enReq := httptest.NewRequest(http.MethodGet, "/v1/meta/enums", nil) + enReq.Header.Set("X-Language", "en") + enW := httptest.NewRecorder() + r.ServeHTTP(enW, enReq) + var enEnv reply.Envelope + if err := json.Unmarshal(enW.Body.Bytes(), &enEnv); err != nil { + t.Fatal(err) + } + var enCat entity.Catalog + if err := json.Unmarshal(enEnv.Data, &enCat); err != nil { + t.Fatal(err) + } + if enCat.Gender[1].Label != "Male" { + t.Fatalf("en label %+v", enCat.Gender) + } +} diff --git a/internal/transport/http/middleware.go b/internal/transport/http/middleware.go index 94d0ab2..8791301 100644 --- a/internal/transport/http/middleware.go +++ b/internal/transport/http/middleware.go @@ -19,21 +19,12 @@ import ( const ( headerRequestID = "X-Request-ID" - headerTraceID = "X-Trace-ID" ) func Trace(base *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { - reqID := c.GetHeader(headerRequestID) - if reqID == "" { - reqID = reqctx.NewID() - } - traceID := c.GetHeader(headerTraceID) - if traceID == "" { - traceID = reqID - } + reqID := reqctx.NewID() rc := &reqctx.RequestContext{ - TraceID: traceID, RequestID: reqID, RemoteAddr: c.Request.RemoteAddr, RealIP: reqctx.ParseHTTPRealIP( @@ -47,7 +38,6 @@ func Trace(base *zap.Logger) gin.HandlerFunc { ctx := reqctx.With(c.Request.Context(), rc) c.Request = c.Request.WithContext(logger.Attach(ctx, base)) c.Header(headerRequestID, reqID) - c.Header(headerTraceID, traceID) c.Next() } } diff --git a/internal/transport/http/respond/respond.go b/internal/transport/http/respond/respond.go index 509bc13..8187d58 100644 --- a/internal/transport/http/respond/respond.go +++ b/internal/transport/http/respond/respond.go @@ -15,7 +15,7 @@ import ( ) func JSON(c *gin.Context, data any) { - c.JSON(http.StatusOK, reply.OK(data)) + c.JSON(http.StatusOK, reply.Stamp(c.Request.Context(), reply.OK(data))) } func JSONError(c *gin.Context, err error) { diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 9e91196..fc6362b 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -10,10 +10,13 @@ import ( "spark-server/internal/pkg/reply" "spark-server/internal/pkg/reqctx" authhandler "spark-server/internal/transport/http/handler/auth" + convhandler "spark-server/internal/transport/http/handler/conversation" favhandler "spark-server/internal/transport/http/handler/favorite" filehandler "spark-server/internal/transport/http/handler/file" friendhandler "spark-server/internal/transport/http/handler/friend" grouphandler "spark-server/internal/transport/http/handler/group" + msghandler "spark-server/internal/transport/http/handler/message" + metahandler "spark-server/internal/transport/http/handler/meta" userhandler "spark-server/internal/transport/http/handler/user" "spark-server/internal/transport/http/respond" ) @@ -22,12 +25,15 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) { registerDocs(r) registerHealth(r, deps.Health) v1 := r.Group("/v1") + registerMetaRoutes(v1) registerAuthRoutes(v1, deps) registerUserRoutes(v1, deps) registerFriendRoutes(v1, deps) registerGroupRoutes(v1, deps) registerFileRoutes(v1, deps) registerFavoriteRoutes(v1, deps) + registerConversationRoutes(v1, deps) + registerMessageRoutes(v1, deps) } func registerHealth(r *gin.Engine, h Health) { @@ -45,14 +51,19 @@ func registerHealth(r *gin.Engine, h Health) { return } lang := reqctx.Lang(c.Request.Context()) - c.JSON(http.StatusServiceUnavailable, reply.FailData( + c.JSON(http.StatusServiceUnavailable, reply.Stamp(c.Request.Context(), reply.FailData( errs.ErrAvailable.Code, i18n.ErrorMsg(lang, errs.ErrAvailable.Code), data, - )) + ))) }) } +func registerMetaRoutes(v1 *gin.RouterGroup) { + h := metahandler.New() + v1.GET("/meta/enums", h.Enums) +} + func registerAuthRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g := v1.Group("/auth") if deps.Login == nil { @@ -89,6 +100,8 @@ func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g.GET("/:uid", h.Get) g.DELETE("/:uid", h.Delete) g.PUT("/:uid/remark", h.Remark) + g.PUT("/:uid/pin", h.Pin) + g.DELETE("/:uid/pin", h.Unpin) g.PUT("/:uid/block", h.Block) g.DELETE("/:uid/block", h.Unblock) g.POST("/:uid/reports", h.Report) @@ -133,6 +146,27 @@ func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g.DELETE("/:id", h.Delete) } +func registerConversationRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Sync == nil { + return + } + h := convhandler.New(deps.Sync) + g := v1.Group("/conversations") + g.GET("", h.Inbox) +} + +func registerMessageRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Messages == nil { + return + } + h := msghandler.New(deps.Messages) + g := v1.Group("/messages") + g.GET("", h.History) + g.POST("", h.Send) + g.POST("/:id/recall", h.Recall) + g.DELETE("/:id", h.Delete) +} + func JSON(c *gin.Context, data any) { respond.JSON(c, data) } diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 5bea5a4..4c69827 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -16,6 +16,8 @@ import ( favlogic "spark-server/internal/module/favorite/logic" friendlogic "spark-server/internal/module/friend/logic" grouplogic "spark-server/internal/module/group/logic" + msglogic "spark-server/internal/module/message/logic" + synclogic "spark-server/internal/module/sync/logic" uploadlogic "spark-server/internal/module/upload/logic" userlogic "spark-server/internal/module/user/logic" "spark-server/internal/pkg/ratelimit" @@ -43,6 +45,8 @@ type EngineDeps struct { Groups *grouplogic.Service Files *uploadlogic.Service Favorites *favlogic.Service + Messages *msglogic.Service + Sync *synclogic.Service Health Health AuthLimiter *ratelimit.Limiter } diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go index 48c1ddc..a63cfbc 100644 --- a/internal/transport/http/server_test.go +++ b/internal/transport/http/server_test.go @@ -27,14 +27,18 @@ import ( func TestHealthzEnvelopeAndTraceHeaders(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{}) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - req.Header.Set("X-Request-ID", "req-1") + req.Header.Set("X-Request-ID", "client-should-be-ignored") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d", w.Code) } - if w.Header().Get("X-Request-ID") != "req-1" || w.Header().Get("X-Trace-ID") != "req-1" { - t.Fatalf("headers %v", w.Header()) + got := w.Header().Get("X-Request-ID") + if len(got) != 32 { + t.Fatalf("server request id %q headers %v", got, w.Header()) + } + if w.Header().Get("X-Trace-ID") != "" { + t.Fatalf("trace header still set %v", w.Header()) } var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { @@ -43,6 +47,9 @@ func TestHealthzEnvelopeAndTraceHeaders(t *testing.T) { if env.Code != "0" || env.Msg != "ok" { t.Fatalf("%+v", env) } + if env.RequestID != got || len(env.RequestID) != 32 { + t.Fatalf("body request_id %q header %q", env.RequestID, got) + } if !bytes.Contains(env.Data, []byte(`"status":"ok"`)) { t.Fatalf("data %s", env.Data) } @@ -75,6 +82,9 @@ func TestReadyzHTTPStatus(t *testing.T) { if env.Code != errs.ErrAvailable.Code || !bytes.Contains(env.Data, []byte(`"mysql":"down"`)) { t.Fatalf("%+v", env) } + if len(env.RequestID) != 32 { + t.Fatalf("readyz request_id %q", env.RequestID) + } } func TestDocsAndOpenAPI(t *testing.T) { @@ -84,11 +94,25 @@ func TestDocsAndOpenAPI(t *testing.T) { if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "openapi: 3.0.3") || !strings.Contains(w.Body.String(), "bearerAuth") { t.Fatalf("spec %d %s", w.Code, w.Body.String()) } + spec := w.Body.String() + if !strings.Contains(spec, "name: enums") || !strings.Contains(spec, "name: errors") { + t.Fatalf("spec missing enum/error tags") + } w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) - if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swagger-ui") { + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swagger-ui") || !strings.Contains(w.Body.String(), "/docs/enums") || !strings.Contains(w.Body.String(), "/docs/errors") { t.Fatalf("docs %d", w.Code) } + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/enums", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "id=\"enum-toc\"") { + t.Fatalf("enums page %d", w.Code) + } + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/errors", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "id=\"error-toc\"") { + t.Fatalf("errors page %d", w.Code) + } } func TestJSONErrorEnvelope(t *testing.T) { diff --git a/internal/transport/tcp/dispatch.go b/internal/transport/tcp/dispatch.go index 752c687..f501d28 100644 --- a/internal/transport/tcp/dispatch.go +++ b/internal/transport/tcp/dispatch.go @@ -2,7 +2,6 @@ package tcp import ( "context" - "strconv" "go.uber.org/zap" @@ -33,7 +32,7 @@ func (s *Server) handleInbound(st *connState, frame protocol.Frame) { } switch frame.Command { case protocol.CmdHeartbeat: - s.writeEncoded(st, replyFrame(protocol.CmdHeartbeat, frame.SequenceID, frame.RequestID, protocol.EncodeOK(nil), 0)) + s.writeEncoded(st, replyFrame(protocol.CmdHeartbeat, frame.SequenceID, frame.RequestID, protocol.EncodeOK(st.sess.RequestID(), nil), 0)) case protocol.CmdPushAck: return default: @@ -55,11 +54,6 @@ func ensureAuth(conn *session.Conn, cmd uint16) error { func (s *Server) submitBusiness(st *connState, frame protocol.Frame) { rc := st.sess.RequestContext() - if frame.RequestID != 0 { - rc.RequestID = strconv.FormatUint(frame.RequestID, 10) - } else { - rc.RequestID = reqctx.NewID() - } ctx := logger.Attach(reqctx.With(context.Background(), rc), s.log) copied := frame err := s.pool.Submit(ctx, func() { @@ -78,7 +72,7 @@ func (s *Server) runHandler(ctx context.Context, conn *session.Conn, frame proto var respBody []byte if err != nil { s.logAppErr(ctx, err) - respBody = protocol.EncodeErrLang(i18n.Parse(conn.Language()), err) + respBody = protocol.EncodeErrLang(conn.RequestID(), i18n.Parse(conn.Language()), err) _ = conn.Send(&session.Packet{ Command: frame.Command, SequenceID: frame.SequenceID, RequestID: frame.RequestID, Body: respBody, }) @@ -98,11 +92,8 @@ func (s *Server) runHandler(ctx context.Context, conn *session.Conn, frame proto func (s *Server) replyErr(st *connState, frame protocol.Frame, err error) { rc := st.sess.RequestContext() - if frame.RequestID != 0 { - rc.RequestID = strconv.FormatUint(frame.RequestID, 10) - } s.logAppErr(logger.Attach(reqctx.With(context.Background(), rc), s.log), err) - s.writeEncoded(st, replyFrame(frame.Command, frame.SequenceID, frame.RequestID, protocol.EncodeErrLang(i18n.Parse(st.sess.Language()), err), 0)) + s.writeEncoded(st, replyFrame(frame.Command, frame.SequenceID, frame.RequestID, protocol.EncodeErrLang(st.sess.RequestID(), i18n.Parse(st.sess.Language()), err), 0)) } func (s *Server) logAppErr(ctx context.Context, err error) { diff --git a/internal/transport/tcp/handlers.go b/internal/transport/tcp/handlers.go index cbf07de..a02065f 100644 --- a/internal/transport/tcp/handlers.go +++ b/internal/transport/tcp/handlers.go @@ -12,6 +12,11 @@ import ( func (s *Server) registerDefaultHandlers() { s.router.Register(protocol.CmdAuth, s.handleAuth) s.router.Register(protocol.CmdGenerateMsgID, s.handleGenerateMsgID) + s.router.Register(protocol.CmdSendMessage, s.handleSendMessage) + s.router.Register(protocol.CmdRecallMessage, s.handleRecallMessage) + s.router.Register(protocol.CmdDeleteMessage, s.handleDeleteMessage) + s.router.Register(protocol.CmdSyncInbox, s.handleSyncInbox) + s.router.Register(protocol.CmdSyncMessages, s.handleSyncMessages) } func (s *Server) handleAuth(ctx context.Context, conn *session.Conn, frame protocol.Frame) (*protocol.Frame, error) { @@ -35,7 +40,7 @@ func (s *Server) handleAuth(ctx context.Context, conn *session.Conn, frame proto return nil, err } conn.Activate() - body := protocol.EncodeOK(protocol.AuthResponse{UserID: principal.UserID, DeviceID: principal.DeviceID}) + body := protocol.EncodeOK(conn.RequestID(), protocol.AuthResponse{UserID: principal.UserID, DeviceID: principal.DeviceID}) return &protocol.Frame{ Version: protocol.CurrentVersion, Command: protocol.CmdAuth, RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: body, @@ -55,6 +60,6 @@ func (s *Server) handleGenerateMsgID(_ context.Context, conn *session.Conn, fram } return &protocol.Frame{ Version: protocol.CurrentVersion, Command: protocol.CmdGenerateMsgID, - RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeMsgID(id), + RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeMsgID(conn.RequestID(), id), }, nil } diff --git a/internal/transport/tcp/message.go b/internal/transport/tcp/message.go new file mode 100644 index 0000000..b029732 --- /dev/null +++ b/internal/transport/tcp/message.go @@ -0,0 +1,123 @@ +package tcp + +import ( + "context" + "encoding/json" + + msglogic "spark-server/internal/module/message/logic" + syncentity "spark-server/internal/module/sync/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/transport/tcp/protocol" + "spark-server/internal/transport/tcp/session" +) + +type msgIDBody struct { + MsgID int64 `json:"msg_id"` +} + +type syncInboxBody struct { + MailboxSeq int64 `json:"mailbox_seq"` + DialogScore int64 `json:"dialog_score"` + Limit int `json:"limit"` +} + +type syncMessagesBody struct { + ConversationID int64 `json:"conversation_id"` + LastMsgID int64 `json:"last_msg_id"` + Limit int `json:"limit"` +} + +func (s *Server) handleSendMessage(ctx context.Context, conn *session.Conn, frame protocol.Frame) (*protocol.Frame, error) { + if s.messages == nil { + return nil, errs.ErrAvailable + } + var in msglogic.SendInput + if err := json.Unmarshal(frame.Body, &in); err != nil { + return nil, errs.ErrInvalidArgument + } + out, err := s.messages.SendMessage(ctx, conn.UserID(), conn.DeviceID(), &in) + if err != nil { + return nil, err + } + return &protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdSendAck, + RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeOK(conn.RequestID(), out), + }, nil +} + +func (s *Server) handleRecallMessage(ctx context.Context, conn *session.Conn, frame protocol.Frame) (*protocol.Frame, error) { + if s.messages == nil { + return nil, errs.ErrAvailable + } + var in msgIDBody + if err := json.Unmarshal(frame.Body, &in); err != nil || in.MsgID <= 0 { + return nil, errs.ErrInvalidArgument + } + if err := s.messages.RecallMessage(ctx, conn.UserID(), conn.DeviceID(), in.MsgID); err != nil { + return nil, err + } + return &protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdRecallMessage, + RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeOK(conn.RequestID(), in), + }, nil +} + +func (s *Server) handleDeleteMessage(ctx context.Context, conn *session.Conn, frame protocol.Frame) (*protocol.Frame, error) { + if s.messages == nil { + return nil, errs.ErrAvailable + } + var in msgIDBody + if err := json.Unmarshal(frame.Body, &in); err != nil || in.MsgID <= 0 { + return nil, errs.ErrInvalidArgument + } + if err := s.messages.DeleteMessage(ctx, conn.UserID(), conn.DeviceID(), in.MsgID); err != nil { + return nil, err + } + return &protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdDeleteMessage, + RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeOK(conn.RequestID(), in), + }, nil +} + +func (s *Server) handleSyncInbox(ctx context.Context, conn *session.Conn, frame protocol.Frame) (*protocol.Frame, error) { + if s.sync == nil { + return nil, errs.ErrAvailable + } + var in syncInboxBody + if len(frame.Body) > 0 { + if err := json.Unmarshal(frame.Body, &in); err != nil { + return nil, errs.ErrInvalidArgument + } + } + out, err := s.sync.SyncInbox(ctx, syncentity.InboxRequest{ + UserID: conn.UserID(), DeviceID: conn.DeviceID(), + MailboxSeq: in.MailboxSeq, DialogScore: in.DialogScore, Limit: in.Limit, + }) + if err != nil { + return nil, err + } + return &protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdSyncInbox, + RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeOK(conn.RequestID(), out), + }, nil +} + +func (s *Server) handleSyncMessages(ctx context.Context, conn *session.Conn, frame protocol.Frame) (*protocol.Frame, error) { + if s.messages == nil { + return nil, errs.ErrAvailable + } + var in syncMessagesBody + if err := json.Unmarshal(frame.Body, &in); err != nil || in.ConversationID <= 0 { + return nil, errs.ErrInvalidArgument + } + out, err := s.messages.GetHistory(ctx, conn.UserID(), msglogic.HistoryRequest{ + ConversationID: in.ConversationID, LastMsgID: in.LastMsgID, Limit: in.Limit, + }) + if err != nil { + return nil, err + } + return &protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdSyncMessages, + RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: protocol.EncodeOK(conn.RequestID(), out), + }, nil +} diff --git a/internal/transport/tcp/protocol/envelope.go b/internal/transport/tcp/protocol/envelope.go index 4f49535..1d7ae84 100644 --- a/internal/transport/tcp/protocol/envelope.go +++ b/internal/transport/tcp/protocol/envelope.go @@ -5,16 +5,16 @@ import ( "spark-server/internal/pkg/reply" ) -func EncodeOK(data any) []byte { - return reply.MustMarshal(reply.OK(data)) +func EncodeOK(requestID string, data any) []byte { + return reply.MustMarshal(reply.StampID(reply.OK(data), requestID)) } -func EncodeErr(err error) []byte { - return EncodeErrLang(i18n.Default, err) +func EncodeErr(requestID string, err error) []byte { + return EncodeErrLang(requestID, i18n.Default, err) } -func EncodeErrLang(lang i18n.Lang, err error) []byte { - return reply.MustMarshal(reply.FromErrorLang(lang, err)) +func EncodeErrLang(requestID string, lang i18n.Lang, err error) []byte { + return reply.MustMarshal(reply.StampID(reply.FromErrorLang(lang, err), requestID)) } func DecodeEnvelope(body []byte) (reply.Envelope, error) { diff --git a/internal/transport/tcp/protocol/frame.go b/internal/transport/tcp/protocol/frame.go index 1a311e4..120efd9 100644 --- a/internal/transport/tcp/protocol/frame.go +++ b/internal/transport/tcp/protocol/frame.go @@ -26,6 +26,7 @@ const ( CmdSyncMessages uint16 = 12 CmdSyncMessagesFinish uint16 = 13 CmdKick uint16 = 14 + CmdDeleteMessage uint16 = 15 ) // Frame is the application header after TLS. SequenceID matches request/response and PUSH_ACK only. diff --git a/internal/transport/tcp/protocol/payload.go b/internal/transport/tcp/protocol/payload.go index 36302f8..46db02b 100644 --- a/internal/transport/tcp/protocol/payload.go +++ b/internal/transport/tcp/protocol/payload.go @@ -48,8 +48,8 @@ func DecodeAuthRequest(body []byte) (AuthRequest, error) { return req, nil } -func EncodeError(e ErrorBody) []byte { - return reply.MustMarshal(reply.Fail(e.Code, e.Message)) +func EncodeError(requestID string, e ErrorBody) []byte { + return reply.MustMarshal(reply.StampID(reply.Fail(e.Code, e.Message), requestID)) } func DecodeError(body []byte) (ErrorBody, error) { @@ -60,10 +60,10 @@ func DecodeError(body []byte) (ErrorBody, error) { return ErrorBody{Code: env.Code, Message: env.Msg}, nil } -func EncodeMsgID(id int64) []byte { - return EncodeOK(MsgIDBody{MsgID: id}) +func EncodeMsgID(requestID string, id int64) []byte { + return EncodeOK(requestID, MsgIDBody{MsgID: id}) } -func EncodeKick(reason string) []byte { - return EncodeOK(KickBody{Reason: reason}) +func EncodeKick(requestID, reason string) []byte { + return EncodeOK(requestID, KickBody{Reason: reason}) } diff --git a/internal/transport/tcp/protocol/payload_test.go b/internal/transport/tcp/protocol/payload_test.go index 6fb0c31..8a4b6f6 100644 --- a/internal/transport/tcp/protocol/payload_test.go +++ b/internal/transport/tcp/protocol/payload_test.go @@ -18,3 +18,14 @@ func TestAuthRequestJSONRoundTrip(t *testing.T) { t.Fatal("garbage") } } + +func TestEncodeOKStampsRequestID(t *testing.T) { + raw := EncodeOK("rid-tcp", map[string]int{"n": 1}) + env, err := DecodeEnvelope(raw) + if err != nil { + t.Fatal(err) + } + if env.Code != "0" || env.RequestID != "rid-tcp" { + t.Fatalf("%+v %s", env, raw) + } +} diff --git a/internal/transport/tcp/server.go b/internal/transport/tcp/server.go index 64c1a5f..b082498 100644 --- a/internal/transport/tcp/server.go +++ b/internal/transport/tcp/server.go @@ -14,6 +14,8 @@ import ( "go.uber.org/zap" authlogic "spark-server/internal/module/auth/logic" + msglogic "spark-server/internal/module/message/logic" + synclogic "spark-server/internal/module/sync/logic" "spark-server/internal/pkg/ratelimit" "spark-server/internal/pkg/reqctx" "spark-server/internal/transport/tcp/session" @@ -61,6 +63,8 @@ type Server struct { handlerTimeout time.Duration auth authlogic.Service ids MsgIDGenerator + messages *msglogic.Service + sync *synclogic.Service router *Router metrics Metrics authLimit *ratelimit.Limiter @@ -88,6 +92,8 @@ type Options struct { HandlerTimeout time.Duration Auth authlogic.Service IDs MsgIDGenerator + Messages *msglogic.Service + Sync *synclogic.Service Metrics Metrics AuthLimiter *ratelimit.Limiter } @@ -115,6 +121,8 @@ func NewServer(log *zap.Logger, pool TaskPool, sessions session.Manager, o *Opti handlerTimeout: o.HandlerTimeout, auth: o.Auth, ids: o.IDs, + messages: o.Messages, + sync: o.Sync, metrics: o.Metrics, authLimit: o.AuthLimiter, router: NewRouter(), @@ -276,7 +284,7 @@ func (s *Server) attach(c gnet.Conn) { remote = addr.String() } sess := session.NewConn(id, remote, s.outboundSize) - sess.SetTraceID(reqctx.NewID()) + sess.SetRequestID(reqctx.NewID()) if !s.proxyProtocol { sess.MarkProxySkipped() } diff --git a/internal/transport/tcp/session/conn.go b/internal/transport/tcp/session/conn.go index a7c150f..700a9ed 100644 --- a/internal/transport/tcp/session/conn.go +++ b/internal/transport/tcp/session/conn.go @@ -31,7 +31,7 @@ type Conn struct { proxyDone atomic.Bool closed atomic.Bool - traceID string + requestID string outbound chan *Packet write func([]byte) error closeFn func() error @@ -64,8 +64,8 @@ func (c *Conn) SetWake(wake func()) { c.wake = wake } -func (c *Conn) SetTraceID(id string) { c.traceID = id } -func (c *Conn) TraceID() string { return c.traceID } +func (c *Conn) SetRequestID(id string) { c.requestID = id } +func (c *Conn) RequestID() string { return c.requestID } func (c *Conn) ID() string { return c.id } func (c *Conn) UserID() int64 { return c.userID.Load() } @@ -208,7 +208,7 @@ func (c *Conn) DrainOutbound(limit int) []*Packet { func (c *Conn) RequestContext() *reqctx.RequestContext { return &reqctx.RequestContext{ - TraceID: c.traceID, + RequestID: c.requestID, UserID: c.UserID(), DeviceID: c.DeviceID(), ConnectionID: c.id, diff --git a/internal/transport/tcp/session/kick.go b/internal/transport/tcp/session/kick.go index 935fba4..9cb4833 100644 --- a/internal/transport/tcp/session/kick.go +++ b/internal/transport/tcp/session/kick.go @@ -24,7 +24,7 @@ func (m *memManager) kickSameDevice(incoming Session) { reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickReplaced, nil) _ = existing.Send(&Packet{ Command: protocol.CmdKick, - Body: protocol.EncodeKick(reason), + Body: protocol.EncodeKick(existing.RequestID(), reason), }) _ = existing.Close() delete(m.byID, existing.ID()) @@ -46,7 +46,7 @@ func (m *memManager) KickDevice(userID int64, deviceID uint64) { reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickLogout, nil) _ = existing.Send(&Packet{ Command: protocol.CmdKick, - Body: protocol.EncodeKick(reason), + Body: protocol.EncodeKick(existing.RequestID(), reason), }) _ = existing.Close() delete(m.byID, id) diff --git a/internal/transport/tcp/session/manager.go b/internal/transport/tcp/session/manager.go index c16f7ab..3da0d8a 100644 --- a/internal/transport/tcp/session/manager.go +++ b/internal/transport/tcp/session/manager.go @@ -20,6 +20,7 @@ type Session interface { DeviceID() uint64 RealIP() string Language() string + RequestID() string Send(packet *Packet) error Close() error } diff --git a/migrations/0007_friends.sql b/migrations/0007_friends.sql index fc7f2e5..597574f 100644 --- a/migrations/0007_friends.sql +++ b/migrations/0007_friends.sql @@ -1,12 +1,20 @@ --- Bidirectional friends, one pending request per pair, unidirectional block/remark/report. +-- Bidirectional friends as two directed rows, one pending request per pair, reports. CREATE TABLE IF NOT EXISTS friend_relations ( - user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行', - user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id', - created_at DATETIME(3) NOT NULL COMMENT '成为好友时间', - PRIMARY KEY (user_small, user_big), - KEY idx_user_big (user_big) -) COMMENT='双向好友。无单向好友;删除任一方即解除双方关系'; + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增', + user_id BIGINT NOT NULL COMMENT '关系所属用户', + friend_id BIGINT NOT NULL COMMENT '对方 user_id', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=好友 0=非好友(仅用于单向拉黑占位)', + remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '备注,仅自己可见', + is_blocked TINYINT NOT NULL DEFAULT 0 COMMENT '是否拉黑对方', + is_pinned TINYINT NOT NULL DEFAULT 0 COMMENT '会话消息置顶', + source TINYINT NOT NULL DEFAULT 0 COMMENT '添加来源:0=未知 1=二维码 2=邮箱 3=spark_id', + created_at DATETIME(3) NOT NULL COMMENT '成为好友或建边时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_user_friend (user_id, friend_id), + KEY idx_user_status (user_id, status) +) COMMENT='单向好友边。A 与 B 互为好友时存两行,各自维护备注、拉黑、置顶'; CREATE TABLE IF NOT EXISTS friend_requests ( id BIGINT PRIMARY KEY COMMENT 'Snowflake 申请 id', @@ -14,7 +22,7 @@ CREATE TABLE IF NOT EXISTS friend_requests ( to_user BIGINT NOT NULL COMMENT '被申请人 user_id', source TINYINT NOT NULL COMMENT '来源:1=二维码 2=邮箱 3=spark_id', reason VARCHAR(128) NOT NULL DEFAULT '' COMMENT '申请理由', - remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '申请人对对方的备注,同意后写入 friend_remarks', + remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '申请人对对方的备注,同意后写入自己的好友边', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=待处理 2=已同意 3=已拒绝', created_at DATETIME(3) NOT NULL COMMENT '首次申请时间', updated_at DATETIME(3) NOT NULL COMMENT '最近一次申请或处理时间', @@ -22,22 +30,6 @@ CREATE TABLE IF NOT EXISTS friend_requests ( KEY idx_to_status (to_user, status, updated_at) ) COMMENT='好友申请。同一对 from→to 只保留一行,重复发起覆盖理由与备注'; -CREATE TABLE IF NOT EXISTS friend_remarks ( - user_id BIGINT NOT NULL COMMENT '备注所属用户', - friend_id BIGINT NOT NULL COMMENT '被备注的用户', - remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '备注名,仅对备注者可见', - updated_at DATETIME(3) NOT NULL COMMENT '更新时间', - PRIMARY KEY (user_id, friend_id) -) COMMENT='好友备注。单向,解除好友时删除双方备注'; - -CREATE TABLE IF NOT EXISTS user_blocks ( - user_id BIGINT NOT NULL COMMENT '拉黑人 user_id', - blocked_id BIGINT NOT NULL COMMENT '被拉黑人 user_id', - created_at DATETIME(3) NOT NULL COMMENT '拉黑时间', - PRIMARY KEY (user_id, blocked_id), - KEY idx_blocked (blocked_id) -) COMMENT='拉黑。单向;拉黑时解除好友关系'; - CREATE TABLE IF NOT EXISTS friend_reports ( id BIGINT PRIMARY KEY COMMENT 'Snowflake 投诉 id', user_id BIGINT NOT NULL COMMENT '投诉人 user_id', diff --git a/migrations/0010_message_deletes.sql b/migrations/0010_message_deletes.sql new file mode 100644 index 0000000..139ecc4 --- /dev/null +++ b/migrations/0010_message_deletes.sql @@ -0,0 +1,10 @@ +-- Per-user delete (hide). Does not change messages.status or the peer's history. + +CREATE TABLE IF NOT EXISTS message_deletes ( + user_id BIGINT NOT NULL COMMENT '删除者 user_id,仅该用户历史不可见', + msg_id BIGINT NOT NULL COMMENT '被删除的 Snowflake MsgId', + conversation_id BIGINT NOT NULL COMMENT '所属会话,便于按会话过滤', + created_at DATETIME(3) NOT NULL COMMENT '删除时间', + PRIMARY KEY (user_id, msg_id), + KEY idx_user_conv_msg (user_id, conversation_id, msg_id) +) COMMENT='用户删除消息(只对自己隐藏)。对端仍可见;全员撤回走 messages.status=2'; diff --git a/migrations/0011_split_messages.sql b/migrations/0011_split_messages.sql new file mode 100644 index 0000000..7c9791a --- /dev/null +++ b/migrations/0011_split_messages.sql @@ -0,0 +1,62 @@ +-- Split message body by conversation type. Snowflake MsgId remains PK / sort / sync cursor. + +CREATE TABLE IF NOT EXISTS private_message ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标', + conversation_id BIGINT NOT NULL COMMENT '所属单聊会话 id', + sender_id BIGINT NOT NULL COMMENT '发送者 user_id;系统消息为 0', + receiver_id BIGINT NOT NULL DEFAULT 0 COMMENT '接收者 user_id;系统消息为 0', + client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', + message_type SMALLINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', + content BLOB NOT NULL COMMENT '序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', + created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间', + UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id), + KEY idx_conversation_id (conversation_id, id), + KEY idx_receiver_id (receiver_id, id) +) COMMENT='单聊消息正文。PK=MsgId;与 group_message 分表存储'; + +CREATE TABLE IF NOT EXISTS group_message ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标', + conversation_id BIGINT NOT NULL COMMENT '所属群会话 id', + group_id BIGINT NOT NULL DEFAULT 0 COMMENT '群号 groups.id,便于按群查询', + sender_id BIGINT NOT NULL COMMENT '发送者 user_id;系统消息为 0', + client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', + message_type SMALLINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', + content BLOB NOT NULL COMMENT '序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', + created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间', + UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id), + KEY idx_conversation_id (conversation_id, id), + KEY idx_group_id (group_id, id) +) COMMENT='群聊消息正文。PK=MsgId;群热路径不写 MySQL inbox 行'; + +CREATE TABLE IF NOT EXISTS messages ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标', + conversation_id BIGINT NOT NULL COMMENT '所属会话 id', + sender_id BIGINT NOT NULL COMMENT '发送者 user_id', + client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', + message_type SMALLINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', + content BLOB NOT NULL COMMENT '序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', + created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间', + UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id), + KEY idx_conversation_id (conversation_id, id) +) COMMENT='迁移中转表。0011 拷贝到 private_message / group_message 后删除'; + +INSERT IGNORE INTO private_message (id, conversation_id, sender_id, client_msg_id, message_type, content, status, created_at) +SELECT m.id, m.conversation_id, m.sender_id, m.client_msg_id, m.message_type, m.content, m.status, m.created_at +FROM messages m +INNER JOIN conversations c ON c.id = m.conversation_id AND c.type = 1; + +INSERT IGNORE INTO group_message (id, conversation_id, sender_id, client_msg_id, message_type, content, status, created_at) +SELECT m.id, m.conversation_id, m.sender_id, m.client_msg_id, m.message_type, m.content, m.status, m.created_at +FROM messages m +INNER JOIN conversations c ON c.id = m.conversation_id AND c.type = 2; + +INSERT IGNORE INTO private_message (id, conversation_id, sender_id, client_msg_id, message_type, content, status, created_at) +SELECT m.id, m.conversation_id, m.sender_id, m.client_msg_id, m.message_type, m.content, m.status, m.created_at +FROM messages m +LEFT JOIN conversations c ON c.id = m.conversation_id +WHERE c.id IS NULL; + +DROP TABLE IF EXISTS messages; diff --git a/migrations/0012_group_profile.sql b/migrations/0012_group_profile.sql new file mode 100644 index 0000000..c3d8212 --- /dev/null +++ b/migrations/0012_group_profile.sql @@ -0,0 +1,7 @@ +-- Group announcement (owner/admin) and per-member remark (self only). + +ALTER TABLE `groups` + ADD COLUMN announcement VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '群公告,仅群主/管理员可改' AFTER avatar; + +ALTER TABLE group_members + ADD COLUMN remark VARCHAR(32) NOT NULL DEFAULT '' COMMENT '成员对该群的备注,仅自己可见' AFTER nickname; diff --git a/migrations/0013_friend_directed.sql b/migrations/0013_friend_directed.sql new file mode 100644 index 0000000..d0b3baf --- /dev/null +++ b/migrations/0013_friend_directed.sql @@ -0,0 +1,38 @@ +-- Pair-shaped friend_relations (user_small, user_big) → two directed rows. +-- Skipped by cmd/migrate when friend_relations.user_id already exists. + +CREATE TABLE IF NOT EXISTS friend_relations_new ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增', + user_id BIGINT NOT NULL COMMENT '关系所属用户', + friend_id BIGINT NOT NULL COMMENT '对方 user_id', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=好友 0=非好友(仅用于单向拉黑占位)', + remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '备注,仅自己可见', + is_blocked TINYINT NOT NULL DEFAULT 0 COMMENT '是否拉黑对方', + is_pinned TINYINT NOT NULL DEFAULT 0 COMMENT '会话消息置顶', + source TINYINT NOT NULL DEFAULT 0 COMMENT '添加来源:0=未知 1=二维码 2=邮箱 3=spark_id', + created_at DATETIME(3) NOT NULL COMMENT '成为好友或建边时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_user_friend (user_id, friend_id), + KEY idx_user_status (user_id, status) +) COMMENT='单向好友边。A 与 B 互为好友时存两行,各自维护备注、拉黑、置顶'; + +INSERT IGNORE INTO friend_relations_new (user_id, friend_id, status, remark, is_blocked, is_pinned, source, created_at, updated_at) +SELECT r.user_small, r.user_big, 1, IFNULL(n.remark, ''), 0, 0, 0, r.created_at, r.created_at +FROM friend_relations r +LEFT JOIN friend_remarks n ON n.user_id = r.user_small AND n.friend_id = r.user_big; + +INSERT IGNORE INTO friend_relations_new (user_id, friend_id, status, remark, is_blocked, is_pinned, source, created_at, updated_at) +SELECT r.user_big, r.user_small, 1, IFNULL(n.remark, ''), 0, 0, 0, r.created_at, r.created_at +FROM friend_relations r +LEFT JOIN friend_remarks n ON n.user_id = r.user_big AND n.friend_id = r.user_small; + +INSERT INTO friend_relations_new (user_id, friend_id, status, remark, is_blocked, is_pinned, source, created_at, updated_at) +SELECT b.user_id, b.blocked_id, 0, '', 1, 0, 0, b.created_at, b.created_at +FROM user_blocks b +ON DUPLICATE KEY UPDATE is_blocked = 1, updated_at = VALUES(updated_at); + +DROP TABLE IF EXISTS friend_remarks; +DROP TABLE IF EXISTS user_blocks; +DROP TABLE IF EXISTS friend_relations; +RENAME TABLE friend_relations_new TO friend_relations; diff --git a/migrations/0014_message_query_keys.sql b/migrations/0014_message_query_keys.sql new file mode 100644 index 0000000..50587fe --- /dev/null +++ b/migrations/0014_message_query_keys.sql @@ -0,0 +1,23 @@ +-- Add denormalized query keys. Ignored when 0011 already created the columns. + +ALTER TABLE private_message + ADD COLUMN receiver_id BIGINT NOT NULL DEFAULT 0 COMMENT '接收者 user_id;系统消息为 0'; + +ALTER TABLE private_message + ADD KEY idx_receiver_id (receiver_id, id); + +UPDATE private_message pm +INNER JOIN p2p_conversations p ON p.conversation_id = pm.conversation_id +SET pm.receiver_id = IF(pm.sender_id = p.user_small, p.user_big, IF(pm.sender_id = p.user_big, p.user_small, 0)) +WHERE pm.receiver_id = 0 AND pm.sender_id <> 0; + +ALTER TABLE group_message + ADD COLUMN group_id BIGINT NOT NULL DEFAULT 0 COMMENT '群号 groups.id,便于按群查询'; + +ALTER TABLE group_message + ADD KEY idx_group_id (group_id, id); + +UPDATE group_message gm +INNER JOIN `groups` g ON g.conversation_id = gm.conversation_id +SET gm.group_id = g.id +WHERE gm.group_id = 0;