feat: 私聊 群聊 群管理 上传

This commit is contained in:
zhr 2026-09-18 14:50:41 +08:00
parent ae9a71034d
commit 8aa8e0bad9
168 changed files with 6406 additions and 1002 deletions

View File

@ -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`

View File

@ -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.

View File

@ -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.

View File

@ -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. |

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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 再压缩(阈值可配)。

View File

@ -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() {}
}

View File

@ -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<String, Error> = .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) ?? "<gzip \(payload.count)B>"))
}
}
}
}

View File

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

View File

@ -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()
}

View File

@ -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")
}
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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

View File

@ -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()

View File

@ -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(),
}
}

View File

@ -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:"-"`
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -1,10 +0,0 @@
package entity
import "time"
// Block is unidirectional: UserID blocked BlockedID.
type Block struct {
UserID int64
BlockedID int64
CreatedAt time.Time
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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 {

View File

@ -26,7 +26,7 @@ type ApplyInput struct {
// ApplyResult is returned after applying or auto-accepting a reverse request.
type ApplyResult struct {
Status string `json:"status"`
Status entity.RequestStatus `json:"status"`
RequestID int64 `json:"request_id,omitempty"`
UID int64 `json:"uid"`
}
@ -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,

View File

@ -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 {

View File

@ -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

View File

@ -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()

View File

@ -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 !ab.Active() {
return false, nil
}
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
}

View File

@ -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,
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -2,7 +2,6 @@ package logic
import (
"context"
"errors"
"spark-server/internal/module/friend/entity"
userentity "spark-server/internal/module/user/entity"
@ -14,13 +13,15 @@ type UserCard struct {
SparkID string `json:"spark_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Gender int `json:"gender"`
Gender userentity.Gender `json:"gender"`
}
type FriendView struct {
UserCard
Remark string `json:"remark"`
Relation int `json:"relation"`
Relation entity.RelationKind `json:"relation"`
Blocked bool `json:"blocked"`
Pinned bool `json:"pinned"`
CreatedAt int64 `json:"created_at,omitempty"`
}
@ -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
}

View File

@ -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
}

View File

@ -13,7 +13,7 @@ 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"`
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"`
}

View File

@ -11,7 +11,7 @@ type RequestView struct {
ID int64 `json:"id"`
From UserCard `json:"from"`
Reason string `json:"reason"`
Source int `json:"source"`
Source entity.Source `json:"source"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}

View File

@ -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

View File

@ -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 {
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{})
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 deleteRemarksBetween(tx, a, b)
})
return nil
}

View File

@ -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
})
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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()
}
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)
now := time.Now()
key := edgeKey{UserID: userID, FriendID: friendID}
rel, ok := m.relations[key]
if !ok {
if !blocked {
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})
}
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)
}
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
now := time.Now()
for _, e := range in.Edges {
if e == nil || e.UserID <= 0 || e.FriendID <= 0 {
return errs.ErrInvalidArgument
}
key := pairKey{Small: in.Relation.UserSmall, Big: in.Relation.UserBig}
if _, ok := m.relations[key]; !ok {
cp := *in.Relation
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
}
}
}

View File

@ -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)
}

View File

@ -2,10 +2,12 @@
package entity
const (
StatusActive = 1
StatusDissolved = 2
StatusActive Status = 1
StatusDissolved Status = 2
MaxNameLen = 32
MaxAnnouncementLen = 1024
MaxRemarkLen = 32
MaxMembers = 200
ListDefault = 20
ListMax = 100
@ -13,16 +15,24 @@ const (
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
}

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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"`
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

View File

@ -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

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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 {

View File

@ -12,6 +12,7 @@ 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"`
Announcement *string `json:"announcement" validate:"omitempty,max=1024" label:"field.group_announcement" errmsg:"max:validate.group.announcement_invalid"`
MutedAll *bool `json:"muted_all"`
}
@ -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
}

View File

@ -13,10 +13,13 @@ type View struct {
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 int `json:"role"`
Role entity.Role `json:"role"`
Remark string `json:"remark"`
GroupNickname string `json:"group_nickname"`
CreatedAt int64 `json:"created_at"`
}
@ -25,8 +28,8 @@ type MemberView struct {
SparkID string `json:"spark_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Gender int `json:"gender"`
Role int `json:"role"`
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"`
@ -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 {

View File

@ -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
}
@ -122,6 +123,7 @@ func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{
"name": g.Name,
"avatar": g.Avatar,
"announcement": g.Announcement,
"muted_all": boolInt(g.MutedAll),
"updated_at": msTime(g.UpdatedAtMS),
})

View File

@ -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 {

View File

@ -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
}

View File

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

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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
}

View File

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

View File

@ -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()

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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),
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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),
})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
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.byID[message.ID] = &cp
m.byClient[key] = message.ID
}
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--
}
}
}

View File

@ -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" }

View File

@ -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
}

View File

@ -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
}

Some files were not shown because too many files have changed in this diff Show More