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 handlers, go-playground validator, RealIP, messages, deliver queue, inbox
ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, MsgId, ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, MsgId,
TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP, validator, LoginService, 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 # 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. 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. 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 ## Layers
@ -61,7 +63,7 @@ Not every PK is Snowflake. Do not AUTO_INCREMENT natural keys.
| Kind | Examples | Rule | | 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. | | 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. | | 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. `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` `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. 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). 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 `:9001` then PROXY to gnet `:9000`. See `deploy/nginx/`. 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}`. AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Deliver workers start with `RequeueStale` + `SETNX deliver:done:{msgId}`.
## When adding a feature ## 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. - List/unread go to Redis ZSET, not MySQL row-per-member.
- New goroutine I/O does not belong in `OnTraffic`. - 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. - 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`). - 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. - 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. - 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) - [send-path.md](send-path.md)
- [http.md](http.md) - [http.md](http.md)
- [i18n.md](i18n.md) - [i18n.md](i18n.md)
- [enums.md](enums.md)
- `docs/architecture-v3.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 | | 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 | | 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. 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 | | 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` | | 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` | | 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 | | groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
| conversations | `/v1/conversations` | list, mute | | conversations | `/v1/conversations` | list, mute |
| messages | `/v1/messages` | history (sync still prefers TCP) | | messages | `/v1/messages` | history (sync still prefers TCP) |
| files | `/v1/files` | upload credential | | files | `/v1/files` | upload credential |
| meta | `/v1/meta` | `GET /v1/meta/enums`(无需登录) |
Rules: Rules:
- Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP. - 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`. - 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). - Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`).
- OpenAPI `tags` must match these groups. - 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 ## Checklist when adding an HTTP API
@ -103,3 +104,4 @@ Rules:
5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`. 5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`.
6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit. 6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit.
7. Localize errors/notifications via `internal/pkg/i18n`; never hardcode `msg`. 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. | | C1 | MySQL + Snowflake MsgId. No ConversationSeq. |
| C2 | EventLoop ≠ MySQL. Fanout = Redis list + worker (`BRPOPLPUSH`). | | C2 | EventLoop ≠ MySQL. Fanout = Redis list + worker (`BRPOPLPUSH`). |
| C3 | TLS + thin Frame. No custom handshake crypto. | | 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. | | C5 | ACK = COMMIT + enqueue. |
| C6 | PROXY RealIP for limit/audit. | | C6 | PROXY RealIP for limit/audit. |
| C7 | Dialog list in Redis ZSET. Mailbox only for missed push + control events. | | C7 | Dialog list in Redis ZSET. Mailbox only for missed push + control events. |

View File

@ -5,7 +5,7 @@ run:
tests: true tests: true
go: "1.26" go: "1.26"
linters_settings: linters-settings:
funlen: funlen:
lines: 100 lines: 100
statements: 100 statements: 100
@ -87,6 +87,7 @@ issues:
- dupl - dupl
- funlen - funlen
- goconst - goconst
- path: internal/module/.*/entity\.go - path: internal/pkg/i18n/catalog_
linters: linters:
- unused - dupl
- goconst

View File

@ -18,7 +18,7 @@ COPY migrations /app/migrations
COPY deploy/docker/config.docker.yaml /app/configs/config.yaml COPY deploy/docker/config.docker.yaml /app/configs/config.yaml
RUN mkdir -p /app/logs && chown -R spark:spark /app RUN mkdir -p /app/logs && chown -R spark:spark /app
USER spark USER spark
EXPOSE 8080 9000 EXPOSE 8080 9090
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/spark-server"] 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). Spark IM modular monolith. Architecture: [docs/architecture-v3.md](docs/architecture-v3.md).
- Go 1.26 - 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. - 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`). - 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 `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/`. - 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,data}`. AUTH body: `{"token","device_id","app_version","language"}`. - 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) - Public entry is Nginx `:443` only (SNI split)
```bash ```bash

View File

@ -12,6 +12,7 @@ import (
mysqldriver "github.com/go-sql-driver/mysql" mysqldriver "github.com/go-sql-driver/mysql"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
"spark-server/internal/bootstrap" "spark-server/internal/bootstrap"
"spark-server/internal/infrastructure/mysql" "spark-server/internal/infrastructure/mysql"
@ -74,6 +75,10 @@ func run(cfgPath, dir string) error {
gdb := db.Gorm() gdb := db.Gorm()
ctx := context.Background() ctx := context.Background()
for _, path := range files { for _, path := range files {
if skipFriendDirected(gdb, path) {
fmt.Printf("skip %s\n", filepath.Base(path))
continue
}
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return err
@ -120,10 +125,21 @@ func ignorableDDL(err error) bool {
} }
switch me.Number { switch me.Number {
case 1050, // table already exists case 1050, // table already exists
1051, // unknown table
1060, // duplicate column 1060, // duplicate column
1061: // duplicate key name 1061, // duplicate key name
1146: // table doesn't exist
return true return true
default: default:
return false 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 compress: true
mysql: mysql:
dsn: "" dsn: "spark:zpQxymHedkYrr8dK@tcp(47.236.66.9:3307)/spark?charset=utf8mb4&parseTime=True&loc=Local"
slow_threshold: 200ms slow_threshold: 200ms
log_level: "" log_level: ""
max_open_conns: 64 max_open_conns: 64
@ -41,9 +41,9 @@ mysql:
conn_max_idle_time: 10m conn_max_idle_time: 10m
redis: redis:
addr: "" addr: "47.236.66.9:6379"
username: "" username: ""
password: "" password: "kL4co8TEaE68vnMk"
db: 0 db: 0
deliver_queue_key: "deliver:queue" deliver_queue_key: "deliver:queue"
deliver_processing_key: "deliver:processing" deliver_processing_key: "deliver:processing"
@ -83,9 +83,9 @@ message:
msgid_expire_ms: 300000 msgid_expire_ms: 300000
object_storage: object_storage:
endpoint: "" endpoint: "47.236.66.9:9000"
access_key: "" access_key: "rustfs_spark"
secret_key: "" secret_key: 'kWvGtYT/~q+35q;3dSI<^P!MH+bgrpBg'
bucket: "spark" bucket: "spark"
region: "us-east-1" region: "us-east-1"
use_ssl: false use_ssl: false

View File

@ -4,7 +4,7 @@ server:
http: http:
addr: "0.0.0.0:8080" addr: "0.0.0.0:8080"
tcp: tcp:
addr: "0.0.0.0:9000" addr: "0.0.0.0:9090"
proxy_protocol: true proxy_protocol: true
tls: tls:
cert_file: "" cert_file: ""
@ -32,7 +32,7 @@ log:
compress: true compress: true
mysql: 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 slow_threshold: 200ms
log_level: "" log_level: ""
max_open_conns: 64 max_open_conns: 64
@ -50,7 +50,7 @@ redis:
deliver_done_prefix: "deliver:done:" deliver_done_prefix: "deliver:done:"
jwt: jwt:
secret: "dev-only-change-me" secret: "0e624dce0ebe9115c2eb048646a141ccbd88366e7f2c2718065bc3a3838dda95"
issuer: "spark-im" issuer: "spark-im"
ttl: 168h ttl: 168h
refresh_ttl: 720h refresh_ttl: 720h
@ -84,8 +84,8 @@ message:
object_storage: object_storage:
endpoint: "rustfs:9000" endpoint: "rustfs:9000"
access_key: "" access_key: "rustfs_spark"
secret_key: "" secret_key: 'kWvGtYT/~q+35q;3dSI<^P!MH+bgrpBg'
bucket: "spark" bucket: "spark"
region: "us-east-1" region: "us-east-1"
use_ssl: false use_ssl: false

View File

@ -1,13 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deploy spark-server to the ECS host and join mysql/redis/rustfs on shell_default. # 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 set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}" HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}" 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=(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 \ rsync -az --delete \
--exclude '.git/' \ --exclude '.git/' \
--exclude 'bin/' \ --exclude 'bin/' \
@ -20,9 +28,11 @@ rsync -az --delete \
-e "ssh -i $KEY -o BatchMode=yes" \ -e "ssh -i $KEY -o BatchMode=yes" \
"$ROOT/" "$HOST:$REMOTE/" "$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[@]}" "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[@]}" "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[@]}" "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[@]}" "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" "${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"). # 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: services:
spark-server: spark-server:
build: build:
@ -11,10 +13,10 @@ services:
TZ: Asia/Shanghai TZ: Asia/Shanghai
ports: ports:
- "127.0.0.1:18080:8080" - "127.0.0.1:18080:8080"
- "127.0.0.1:19000:9000" - "127.0.0.1:9090:9090"
volumes: volumes:
- /data/spark-server/logs:/app/logs - /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: networks:
- shell - shell

View File

@ -7,7 +7,7 @@ Client --TLS--> :443 ssl_preread
├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS) ├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP │ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS) └─ 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: 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/sparkapi.conf` → `/etc/nginx/conf.d/`
- `host/spark-sni.conf` → `/etc/nginx/stream.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): spark-server (Docker):
- HTTP `127.0.0.1:18080` → container `:8080` - 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 - Public security group: 443 only
```bash ```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). # 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) # default SNI → existing HTTP sites on 127.0.0.1:8443 (proxy_protocol restores RealIP)
map $ssl_preread_server_name $spark_sni_backend { map $ssl_preread_server_name $spark_sni_backend {
@ -9,7 +9,7 @@ map $ssl_preread_server_name $spark_sni_backend {
server { server {
listen 443 reuseport; listen 443 reuseport;
listen [::]:443 reuseport; listen [::]:443 reuseport ipv6only=on;
ssl_preread on; ssl_preread on;
proxy_connect_timeout 5s; proxy_connect_timeout 5s;
proxy_timeout 3600s; proxy_timeout 3600s;
@ -25,5 +25,5 @@ server {
proxy_connect_timeout 5s; proxy_connect_timeout 5s;
proxy_timeout 3600s; proxy_timeout 3600s;
proxy_protocol on; 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() { install_sni() {
rewrite_http_443 rewrite_http_443
rm -f /etc/nginx/conf.d/spark-acme.conf 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/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.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 if grep -RInE 'listen[[:space:]]+(\[::\]:)?443[[:space:]]+ssl' /etc/nginx/conf.d; then
echo "http still binds public 443" >&2 echo "http still binds public 443" >&2
exit 1 exit 1

View File

@ -289,7 +289,7 @@ TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope
```text ```text
Magic u16 | Version u8 | Command u16 | Flags u16 Magic u16 | Version u8 | Command u16 | Flags u16
RequestID u64 | SequenceID u32 | BodyLength u32 RequestID u64 | SequenceID u32 | BodyLength u32
JSON Body {"code","msg","data"} JSON Body {"code","msg","request_id","data"}
``` ```
Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。 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" friendmodel "spark-server/internal/module/friend/model"
grouplogic "spark-server/internal/module/group/logic" grouplogic "spark-server/internal/module/group/logic"
groupmodel "spark-server/internal/module/group/model" 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" 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" uploadlogic "spark-server/internal/module/upload/logic"
userlogic "spark-server/internal/module/user/logic" userlogic "spark-server/internal/module/user/logic"
usermodel "spark-server/internal/module/user/model" 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 := authlogic.NewLoginService(jwtSvc, users)
login.Sessions = sessions login.Sessions = sessions
var deliverQueue deliverlogic.Queue var deliverQueue deliverlogic.Queue
app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions) inbox, mailbox := wireInbox(idgen, db, rdb)
friends := wireFriends(idgen, db, users, deliverQueue) app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions, inbox, mailbox)
groups := wireGroups(idgen, db, users, friends, deliverQueue, objects) 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) 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{ app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
Addr: cfg.Server.TCP.Addr, Addr: cfg.Server.TCP.Addr,
Multicore: cfg.Server.TCP.Multicore, Multicore: cfg.Server.TCP.Multicore,
@ -175,6 +184,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
IDs: idgen, IDs: idgen,
Auth: jwtSvc, Auth: jwtSvc,
AuthLimiter: authLimit, AuthLimiter: authLimit,
Messages: messages,
Sync: syncSvc,
}) })
app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{ app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{
Auth: jwtSvc, Auth: jwtSvc,
@ -184,6 +195,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
Groups: groups, Groups: groups,
Files: files, Files: files,
Favorites: favorites, Favorites: favorites,
Messages: messages,
Sync: syncSvc,
Health: probe, Health: probe,
AuthLimiter: authLimit, 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) return userlogic.NewService(repo, codes, idgen, objects)
} }
func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue) *friendlogic.Service { type chatStores struct {
var repo friendlogic.Repository msgs msglogic.Repository
var chats friendlogic.P2PChats chats msglogic.Conversations
var msgs friendlogic.Messages p2p *convlogic.P2PService
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))
} }
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 repo grouplogic.Repository
var msgs grouplogic.Messages
if db != nil && db.Ready() { if db != nil && db.Ready() {
repo = groupmodel.NewRepo(db.Gorm()) repo = groupmodel.NewRepo(db.Gorm())
msgs = msgmodel.NewRepo(db.Gorm())
} else { } else {
repo = groupmodel.NewMemoryRepo() repo = groupmodel.NewMemoryRepo()
msgs = msgmodel.NewMemoryRepo()
} }
var objects grouplogic.Objects var objects grouplogic.Objects
if store != nil && store.Ready() { if store != nil && store.Ready() {
objects = store 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 { 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 { if idgen == nil {
return nil return nil
} }
var repo favlogic.Repository var repo favlogic.Repository
var msgs favlogic.Messages
var members favlogic.Members
if db != nil && db.Ready() { if db != nil && db.Ready() {
repo = favmodel.NewRepo(db.Gorm()) repo = favmodel.NewRepo(db.Gorm())
msgs = msgmodel.NewRepo(db.Gorm())
members = convmodel.NewRepo(db.Gorm())
} else { } else {
repo = favmodel.NewMemoryRepo() repo = favmodel.NewMemoryRepo()
msgs = msgmodel.NewMemoryRepo()
members = convmodel.NewMemoryP2P()
} }
var objects favlogic.Objects var objects favlogic.Objects
if store != nil && store.Ready() { if store != nil && store.Ready() {
objects = store 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 q deliverlogic.Queue
var done deliverlogic.Deduper var done deliverlogic.Deduper
if rdb != nil && rdb.Ready() { 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{ return deliverlogic.NewWorker(log, q, done, deliverlogic.WorkerOptions{
Workers: cfg.Deliver.Workers, Workers: cfg.Deliver.Workers,
TakeTimeout: cfg.Deliver.TakeTimeout, TakeTimeout: cfg.Deliver.TakeTimeout,
Handler: deliverlogic.SessionFanout(sessions), Handler: deliverlogic.InboxFanout(sessions, inbox, mailbox),
}), q }), 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 { func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
if rdb != nil && rdb.Ready() { if rdb != nil && rdb.Ready() {
return authlogic.NewRedisDenylist(rdb.RDB(), prefix) 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 } 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() 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 { func Attach(ctx context.Context, base *zap.Logger) context.Context {
if base == nil { if base == nil {
base = zap.NewNop() base = zap.NewNop()
@ -42,9 +42,6 @@ func WithReq(log *zap.Logger, rc *reqctx.RequestContext) *zap.Logger {
return log return log
} }
fields := make([]zap.Field, 0, 8) fields := make([]zap.Field, 0, 8)
if rc.TraceID != "" {
fields = append(fields, zap.String("trace_id", rc.TraceID))
}
if rc.RequestID != "" { if rc.RequestID != "" {
fields = append(fields, zap.String("request_id", 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 holds conversation domain types.
package entity package entity
// Type is persisted as conversations.type.
type Type int
const ( const (
TypeP2P = 1 TypeP2P Type = 1
TypeGroup = 2 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. // Conversation is a P2P or group chat. LastMsgID is the Snowflake cursor, not ConversationSeq.
type Conversation struct { type Conversation struct {
ID int64 ID int64
Type int Type Type
LastMsgID int64 LastMsgID int64
LastMsgAt int64 LastMsgAt int64
} }

View File

@ -31,6 +31,25 @@ func (r *ConversationMember) entity() *entity.Member {
return m 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) { func (m *Repo) GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) {
if conversationID <= 0 || userID <= 0 { if conversationID <= 0 || userID <= 0 {
return nil, errs.ErrInvalidArgument return nil, errs.ErrInvalidArgument

View File

@ -70,6 +70,31 @@ func (m *MemoryP2P) CreateP2P(_ context.Context, c *entity.Conversation, p2p *en
return nil 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) { func (m *MemoryP2P) GetMember(_ context.Context, conversationID, userID int64) (*entity.Member, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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 { return db.Transaction(func(tx *gorm.DB) error {
conv := Conversation{ 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), LastMsgAt: msTime(c.LastMsgAt), CreatedAt: msTime(c.LastMsgAt),
} }
if err := tx.Create(&conv).Error; err != nil { if err := tx.Create(&conv).Error; err != nil {
@ -126,7 +126,7 @@ func (r *Conversation) entity() *entity.Conversation {
return nil return nil
} }
return &entity.Conversation{ 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"` NotPush bool `json:"not_push,omitempty"`
NotMailbox bool `json:"not_mailbox,omitempty"` NotMailbox bool `json:"not_mailbox,omitempty"`
Payload []byte `json:"payload,omitempty"` Payload []byte `json:"payload,omitempty"`
Preview []byte `json:"preview,omitempty"`
raw []byte `json:"-"` 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"` ID int64 `json:"id"`
MsgID int64 `json:"msg_id"` MsgID int64 `json:"msg_id"`
ConversationID int64 `json:"conversation_id"` ConversationID int64 `json:"conversation_id"`
MessageType int16 `json:"message_type"` MessageType msgentity.Type `json:"message_type"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
@ -112,6 +112,6 @@ func itemOf(fav *entity.Favorite) *Item {
} }
return &Item{ return &Item{
ID: fav.ID, MsgID: fav.MsgID, ConversationID: fav.ConversationID, 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. // AcceptParams is the transactional write when two users become friends.
type AcceptParams struct { type AcceptParams struct {
Relation *Relation Edges []*Relation
Requests []int64 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" import "time"
const ( const (
RelationNone = 0 RelationNone RelationKind = 0
RelationFriend = 1 RelationFriend RelationKind = 1
RelationBlock = 2 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 ( const (
ListDefault = 20 ListDefault = 20
ListMax = 100 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 { type Relation struct {
UserSmall int64 ID int64
UserBig int64 UserID int64
FriendID int64
Status EdgeStatus
Remark string
Blocked bool
Pinned bool
Source Source
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
} }
func NewRelation(a, b int64, at time.Time) *Relation { func (r *Relation) Active() bool {
small, big := OrderUsers(a, b) return r != nil && r.Status == StatusFriend
return &Relation{UserSmall: small, UserBig: big, CreatedAt: at}
} }
func (r *Relation) Other(me int64) int64 { func NewFriendEdge(userID, friendID int64, remark string, source Source, at time.Time) *Relation {
if r == nil { return &Relation{
return 0 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 { func ClampLimit(limit int) int {
@ -48,8 +65,8 @@ func ClampLimit(limit int) int {
return limit return limit
} }
// ViewStatus is the viewer-centric relation: friend, blocked-by-me, or none. // ViewRelation is the viewer-centric relation: friend, blocked-by-me, or none.
func ViewStatus(friend, blockedByMe bool) int { func ViewRelation(friend, blockedByMe bool) RelationKind {
if blockedByMe { if blockedByMe {
return RelationBlock 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" import "time"
const ( const (
ReportSpam = 1 ReportSpam ReportReason = 1
ReportHarassment = 2 ReportHarassment ReportReason = 2
ReportFake = 3 ReportFake ReportReason = 3
ReportOther = 4 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 const ReportDetailMax = 512
// Report is a complaint about TargetID by UserID. // Report is a complaint about TargetID by UserID.
@ -16,11 +23,11 @@ type Report struct {
ID int64 ID int64
UserID int64 UserID int64
TargetID int64 TargetID int64
Reason int Reason ReportReason
Detail string Detail string
CreatedAt time.Time CreatedAt time.Time
} }
func ValidReportReason(r int) bool { func ValidReportReason(r int) bool {
return r >= ReportSpam && r <= ReportOther return ReportReason(r).Valid()
} }

View File

@ -3,17 +3,32 @@ package entity
import "time" import "time"
const ( const (
RequestPending = 1 RequestPending RequestStatus = 1
RequestAccepted = 2 RequestAccepted RequestStatus = 2
RequestRejected = 3 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 ( const (
SourceQRCode = 1 SourceUnknown Source = 0
SourceEmail = 2 SourceQRCode Source = 1
SourceSparkID = 3 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 ( const (
ReasonMax = 128 ReasonMax = 128
RemarkMax = 64 RemarkMax = 64
@ -24,14 +39,14 @@ type Request struct {
ID int64 ID int64
FromUser int64 FromUser int64
ToUser int64 ToUser int64
Source int Source Source
Reason string Reason string
Remark string Remark string
Status int Status RequestStatus
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
func ValidSource(src int) bool { 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 { func (s *Service) becomeFriends(ctx context.Context, req *entity.Request, accepter int64, accepterRemark string) error {
now := time.Now() 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} ids := []int64{req.ID}
if reverse, err := s.repo.GetRequestByPair(ctx, accepter, req.FromUser); err == nil && reverse.ID != req.ID { if reverse, err := s.repo.GetRequestByPair(ctx, accepter, req.FromUser); err == nil && reverse.ID != req.ID {
ids = append(ids, reverse.ID) ids = append(ids, reverse.ID)
} }
remarks := make([]entity.Remark, 0, 2) if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Edges: edges, Requests: ids}); err != nil {
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 {
return err return err
} }
if s.greeter != nil { if s.greeter != nil {

View File

@ -26,9 +26,9 @@ type ApplyInput struct {
// ApplyResult is returned after applying or auto-accepting a reverse request. // ApplyResult is returned after applying or auto-accepting a reverse request.
type ApplyResult struct { type ApplyResult struct {
Status string `json:"status"` Status entity.RequestStatus `json:"status"`
RequestID int64 `json:"request_id,omitempty"` RequestID int64 `json:"request_id,omitempty"`
UID int64 `json:"uid"` UID int64 `json:"uid"`
} }
func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (*ApplyResult, error) { func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (*ApplyResult, error) {
@ -52,7 +52,7 @@ func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (
if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil { if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil {
return nil, err 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) { } else if err != nil && !errors.Is(err, errs.ErrNotFound) {
return nil, err return nil, err
} }
@ -60,10 +60,10 @@ func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (
if err != nil { if err != nil {
return nil, err 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) lang := reqctx.Lang(ctx)
n := 0 n := 0
if in.UID > 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 err != nil {
if errors.Is(err, errs.ErrNotFound) { if errors.Is(err, errs.ErrNotFound) {
return nil, 0, errs.ErrNotFound return nil, 0, errs.ErrNotFound
@ -144,15 +144,17 @@ func (s *Service) canApply(ctx context.Context, from, to int64) error {
if blockedBy { if blockedBy {
return errs.ErrNotFound return errs.ErrNotFound
} }
if _, err := s.repo.GetRelation(ctx, from, to); err == nil { ok, err := s.AreFriends(ctx, from, to)
return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil)) if err != nil {
} else if !errors.Is(err, errs.ErrNotFound) {
return err return err
} }
if ok {
return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil))
}
return 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() now := time.Now()
req := &entity.Request{ req := &entity.Request{
FromUser: from, ToUser: to, Source: source, FromUser: from, ToUser: to, Source: source,

View File

@ -3,38 +3,36 @@ package logic
import ( import (
"context" "context"
"errors" "errors"
"time"
"spark-server/internal/module/friend/entity"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n" "spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/reqctx"
) )
func (s *Service) Block(ctx context.Context, userID, targetID int64) error { func (s *Service) Block(ctx context.Context, userID, targetID int64) error {
if !s.ready() || userID <= 0 { return s.setBlocked(ctx, userID, targetID, true)
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()})
} }
func (s *Service) Unblock(ctx context.Context, userID, targetID int64) error { 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 { if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
if targetID <= 0 { if targetID <= 0 {
return errs.ErrInvalidArgument 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 { func (s *Service) Delete(ctx context.Context, userID, friendID int64) error {

View File

@ -2,7 +2,6 @@ package logic
import ( import (
"context" "context"
"errors"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
@ -14,12 +13,13 @@ func (s *Service) CanSend(ctx context.Context, fromUserID, toUserID int64) error
if fromUserID == toUserID { if fromUserID == toUserID {
return errs.ErrForbidden return errs.ErrForbidden
} }
if _, err := s.repo.GetRelation(ctx, fromUserID, toUserID); err != nil { ok, err := s.AreFriends(ctx, fromUserID, toUserID)
if errors.Is(err, errs.ErrNotFound) { if err != nil {
return errs.ErrForbidden
}
return err return err
} }
if !ok {
return errs.ErrForbidden
}
blocked, err := s.repo.BlockedEither(ctx, fromUserID, toUserID) blocked, err := s.repo.BlockedEither(ctx, fromUserID, toUserID)
if err != nil { if err != nil {
return err return err

View File

@ -60,7 +60,7 @@ func TestApplyByEmailSparkIDQRAndDedupe(t *testing.T) {
ctx := t.Context() ctx := t.Context()
a, b := twoUsers(t, users) a, b := twoUsers(t, users)
first, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Reason: "hi", Remark: "work"}) 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) t.Fatalf("email apply %+v %v", first, err)
} }
second, err := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID, Reason: "again"}) 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) t.Fatal(err)
} }
out, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com", Remark: "b-note"}) 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) t.Fatalf("%+v %v", out, err)
} }
if g.n != 1 { if g.n != 1 {
@ -151,6 +151,14 @@ func TestNoUnidirectionalDeleteAndBlock(t *testing.T) {
if err := svc.Block(ctx, a.ID, b.ID); err != nil { if err := svc.Block(ctx, a.ID, b.ID); err != nil {
t.Fatal(err) 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) { if err := svc.CanSend(ctx, a.ID, b.ID); !errors.Is(err, errs.ErrForbidden) {
t.Fatalf("blocked send %v", err) 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) { func TestRemarkReportLookup(t *testing.T) {
svc, users, _ := testFriends(t) svc, users, _ := testFriends(t)
ctx := t.Context() 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 { if !s.ready() || a <= 0 || b <= 0 || a == b {
return false, nil return false, nil
} }
_, err := s.repo.GetRelation(ctx, a, b) ab, err := s.repo.GetRelation(ctx, a, b)
if err == nil { if err != nil {
return true, nil if errors.Is(err, errs.ErrNotFound) {
return false, nil
}
return false, err
} }
if errors.Is(err, errs.ErrNotFound) { if !ab.Active() {
return false, nil return false, nil
} }
return false, err ba, err := s.repo.GetRelation(ctx, b, a)
if err != nil {
if errors.Is(err, errs.ErrNotFound) {
return false, nil
}
return false, err
}
return ba.Active(), nil
} }

View File

@ -55,6 +55,7 @@ func (g *SystemGreeter) BecameFriends(ctx context.Context, userA, userB int64) e
SenderID: 0, SenderID: 0,
ClientMsgID: fmt.Sprintf("sys:friend:%d", msgID), ClientMsgID: fmt.Sprintf("sys:friend:%d", msgID),
Type: msgentity.TypeSystem, Type: msgentity.TypeSystem,
Kind: msgentity.KindPrivate,
Content: raw, Content: raw,
Status: msgentity.StatusNormal, Status: msgentity.StatusNormal,
CreatedAtMS: now.UnixMilli(), CreatedAtMS: now.UnixMilli(),
@ -81,7 +82,7 @@ type pushBody struct {
MsgID int64 `json:"msg_id"` MsgID int64 `json:"msg_id"`
ConversationID int64 `json:"conversation_id"` ConversationID int64 `json:"conversation_id"`
SenderID int64 `json:"sender_id"` SenderID int64 `json:"sender_id"`
MessageType int16 `json:"message_type"` MessageType msgentity.Type `json:"message_type"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
@ -89,6 +90,6 @@ type pushBody struct {
func pushBodyOf(m *msgentity.Message) pushBody { func pushBodyOf(m *msgentity.Message) pushBody {
return pushBody{ return pushBody{
MsgID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, 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 { if blockedBy {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
friend := false rel, err := s.repo.GetRelation(ctx, userID, target.ID)
if _, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil { if err != nil && !errors.Is(err, errs.ErrNotFound) {
friend = true
} else if !errors.Is(err, errs.ErrNotFound) {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
remark := "" blockedMe := rel != nil && rel.Blocked
if friend { view := viewOf(target, rel)
remark, err = s.repo.GetRemark(ctx, userID, target.ID) view.Relation = entity.ViewRelation(friend, blockedMe)
if err != nil { if !friend {
return nil, err view.CreatedAt = 0
} view.Pinned = false
} if rel == nil {
view := &FriendView{ view.Remark = ""
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)
} }
} }
return view, nil 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 ( import (
"context" "context"
"errors"
"spark-server/internal/module/friend/entity" "spark-server/internal/module/friend/entity"
userentity "spark-server/internal/module/user/entity" userentity "spark-server/internal/module/user/entity"
@ -10,18 +9,20 @@ import (
) )
type UserCard struct { type UserCard struct {
UID int64 `json:"uid"` UID int64 `json:"uid"`
SparkID string `json:"spark_id"` SparkID string `json:"spark_id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Gender int `json:"gender"` Gender userentity.Gender `json:"gender"`
} }
type FriendView struct { type FriendView struct {
UserCard UserCard
Remark string `json:"remark"` Remark string `json:"remark"`
Relation int `json:"relation"` Relation entity.RelationKind `json:"relation"`
CreatedAt int64 `json:"created_at,omitempty"` Blocked bool `json:"blocked"`
Pinned bool `json:"pinned"`
CreatedAt int64 `json:"created_at,omitempty"`
} }
type FriendList struct { type FriendList struct {
@ -38,6 +39,21 @@ func cardOf(u *userentity.User) UserCard {
} }
} }
func viewOf(u *userentity.User, rel *entity.Relation) *FriendView {
v := &FriendView{UserCard: cardOf(u), Relation: entity.RelationNone}
if rel == nil {
return v
}
v.Remark = rel.Remark
v.Blocked = rel.Blocked
v.Pinned = rel.Pinned
v.Relation = entity.ViewRelation(rel.Active(), rel.Blocked)
if rel.Active() {
v.CreatedAt = entity.UnixMS(rel.CreatedAt)
}
return v
}
func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) { func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) {
if !s.ready() || userID <= 0 { if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
@ -46,29 +62,13 @@ func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendLis
if err != nil { if err != nil {
return nil, err 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)) items := make([]*FriendView, 0, len(rels))
for _, rel := range rels { for _, rel := range rels {
other := rel.Other(userID) u, err := s.users.Get(ctx, rel.FriendID)
u, err := s.users.Get(ctx, other)
if err != nil { if err != nil {
continue continue
} }
items = append(items, &FriendView{ items = append(items, viewOf(u, rel))
UserCard: cardOf(u),
Remark: notes[other],
Relation: entity.RelationFriend,
CreatedAt: entity.UnixMS(rel.CreatedAt),
})
} }
return &FriendList{Items: items}, nil 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) rel, err := s.repo.GetRelation(ctx, userID, friendID)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrNotFound) {
return nil, errs.ErrNotFound
}
return nil, err return nil, err
} }
if !rel.Active() {
return nil, errs.ErrNotFound
}
u, err := s.activeUser(ctx, friendID) u, err := s.activeUser(ctx, friendID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
remark, err := s.repo.GetRemark(ctx, userID, friendID) return viewOf(u, rel), nil
if err != nil {
return nil, err
}
return &FriendView{
UserCard: cardOf(u),
Remark: remark,
Relation: entity.RelationFriend,
CreatedAt: entity.UnixMS(rel.CreatedAt),
}, nil
} }

View File

@ -2,7 +2,6 @@ package logic
import ( import (
"context" "context"
"errors"
"strings" "strings"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
@ -32,12 +31,12 @@ func (s *Service) requireFriend(ctx context.Context, userID, friendID int64) err
if friendID <= 0 { if friendID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
_, err := s.repo.GetRelation(ctx, userID, friendID) ok, err := s.AreFriends(ctx, userID, friendID)
if err == nil { if err != nil {
return nil return err
} }
if errors.Is(err, errs.ErrNotFound) { if !ok {
return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil)) return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil))
} }
return err return nil
} }

View File

@ -13,8 +13,8 @@ import (
) )
type ReportInput struct { 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"` Detail string `json:"detail" validate:"omitempty,max=512" label:"field.report_detail" errmsg:"max:validate.friend.report_detail_invalid"`
} }
func (s *Service) Report(ctx context.Context, userID, targetID int64, in ReportInput) error { func (s *Service) Report(ctx context.Context, userID, targetID int64, in ReportInput) error {

View File

@ -8,12 +8,12 @@ import (
) )
type RequestView struct { type RequestView struct {
ID int64 `json:"id"` ID int64 `json:"id"`
From UserCard `json:"from"` From UserCard `json:"from"`
Reason string `json:"reason"` Reason string `json:"reason"`
Source int `json:"source"` Source entity.Source `json:"source"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
type RequestList struct { type RequestList struct {

View File

@ -10,19 +10,17 @@ import (
// Repository is friend persistence. Model implements it; do not split this by table. // Repository is friend persistence. Model implements it; do not split this by table.
type Repository interface { 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) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error)
GetRequest(ctx context.Context, id int64) (*entity.Request, error) GetRequest(ctx context.Context, id int64) (*entity.Request, error)
GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error) GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error)
UpsertRequest(ctx context.Context, req *entity.Request) error UpsertRequest(ctx context.Context, req *entity.Request) error
ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) ListIncoming(ctx context.Context, toUserID int64, status entity.RequestStatus, 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)
SetRemark(ctx context.Context, userID, friendID int64, remark string) 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) IsBlocked(ctx context.Context, userID, targetID int64) (bool, error)
BlockedEither(ctx context.Context, a, b 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 CreateReport(ctx context.Context, r *entity.Report) error
AcceptFriend(ctx context.Context, in entity.AcceptParams) error AcceptFriend(ctx context.Context, in entity.AcceptParams) error
DeleteFriend(ctx context.Context, a, b int64) 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 { func (m *Repo) AcceptFriend(ctx context.Context, in entity.AcceptParams) error {
if in.Relation == nil { if len(in.Edges) == 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {
return err return err
} }
now := in.Relation.CreatedAt now := time.Now()
if now.IsZero() { for _, e := range in.Edges {
now = time.Now() if e == nil || e.UserID <= 0 || e.FriendID <= 0 {
in.Relation.CreatedAt = now 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 { return db.Transaction(func(tx *gorm.DB) error {
row := FriendRelation{UserSmall: in.Relation.UserSmall, UserBig: in.Relation.UserBig, CreatedAt: now} for _, e := range in.Edges {
if err := tx.Where("user_small = ? AND user_big = ?", row.UserSmall, row.UserBig). row := rowFromEdge(e)
Attrs(row).FirstOrCreate(&row).Error; err != nil { assigns := map[string]any{
return mapDBErr(err) "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 markRequestsAccepted(tx, in.Requests, now)
return err
}
return upsertRemarks(tx, in.Remarks)
}) })
} }
@ -41,15 +60,13 @@ func (m *Repo) DeleteFriend(ctx context.Context, a, b int64) error {
if err != nil { if err != nil {
return err return err
} }
return db.Transaction(func(tx *gorm.DB) error { res := db.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", a, b, b, a).
small, big := entity.OrderUsers(a, b) Delete(&FriendRelation{})
res := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{}) if res.Error != nil {
if res.Error != nil { return mapDBErr(res.Error)
return mapDBErr(res.Error) }
} if res.RowsAffected == 0 {
if res.RowsAffected == 0 { return errs.ErrNotFound
return errs.ErrNotFound }
} return nil
return deleteRemarksBetween(tx, a, b)
})
} }

View File

@ -2,16 +2,27 @@ package model
import ( import (
"context" "context"
"errors"
"time" "time"
"gorm.io/gorm"
"spark-server/internal/module/friend/entity" "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 { type FriendRelation struct {
UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"` ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"` UserID int64 `gorm:"column:user_id;comment:关系所属用户"`
CreatedAt time.Time `gorm:"column:created_at;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" } func (FriendRelation) TableName() string { return "friend_relations" }
@ -20,17 +31,28 @@ func (r *FriendRelation) entity() *entity.Relation {
if r == nil { if r == nil {
return 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) db, err := m.session(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
small, big := entity.OrderUsers(a, b)
var row FriendRelation 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 nil, mapDBErr(err)
} }
return row.entity(), nil return row.entity(), nil
@ -43,8 +65,8 @@ func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*ent
} }
limit = entity.ClampLimit(limit) limit = entity.ClampLimit(limit)
var rows []FriendRelation var rows []FriendRelation
err = db.Where("user_small = ? OR user_big = ?", userID, userID). err = db.Where("user_id = ? AND status = ?", userID, entity.StatusFriend).
Order("created_at DESC").Limit(limit).Find(&rows).Error Order("is_pinned DESC, created_at DESC").Limit(limit).Find(&rows).Error
if err != nil { if err != nil {
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
@ -54,3 +76,88 @@ func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*ent
} }
return out, nil 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{ row := FriendReport{
ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, 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) return mapDBErr(db.Create(&row).Error)
} }

View File

@ -18,7 +18,7 @@ type FriendRequest struct {
ToUser int64 `gorm:"column:to_user;comment:被申请人 user_id"` ToUser int64 `gorm:"column:to_user;comment:被申请人 user_id"`
Source int `gorm:"column:source;comment:来源:1=二维码 2=邮箱 3=spark_id"` Source int `gorm:"column:source;comment:来源:1=二维码 2=邮箱 3=spark_id"`
Reason string `gorm:"column:reason;comment:申请理由"` 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=已拒绝"` Status int `gorm:"column:status;comment:1=待处理 2=已同意 3=已拒绝"`
CreatedAt time.Time `gorm:"column:created_at;comment:首次申请时间"` CreatedAt time.Time `gorm:"column:created_at;comment:首次申请时间"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近一次申请或处理时间"` UpdatedAt time.Time `gorm:"column:updated_at;comment:最近一次申请或处理时间"`
@ -31,16 +31,16 @@ func (r *FriendRequest) entity() *entity.Request {
return nil return nil
} }
return &entity.Request{ return &entity.Request{
ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source, ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: entity.Source(r.Source),
Reason: r.Reason, Remark: r.Remark, Status: r.Status, Reason: r.Reason, Remark: r.Remark, Status: entity.RequestStatus(r.Status),
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
} }
} }
func requestFromEntity(r *entity.Request) FriendRequest { func requestFromEntity(r *entity.Request) FriendRequest {
return FriendRequest{ return FriendRequest{
ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source, ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: int(r.Source),
Reason: r.Reason, Remark: r.Remark, Status: r.Status, Reason: r.Reason, Remark: r.Remark, Status: int(r.Status),
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
} }
} }
@ -93,7 +93,7 @@ func (m *Repo) UpsertRequest(ctx context.Context, req *entity.Request) error {
return nil 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) db, err := m.session(ctx)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -10,9 +10,9 @@ import (
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
type pairKey struct { type edgeKey struct {
Small int64 UserID int64
Big int64 FriendID int64
} }
type requestKey struct { type requestKey struct {
@ -20,41 +20,27 @@ type requestKey struct {
To int64 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. // MemoryRepo is an in-process friend store for tests and empty-DSN bring-up.
type MemoryRepo struct { type MemoryRepo struct {
mu sync.Mutex mu sync.Mutex
relations map[pairKey]*entity.Relation relations map[edgeKey]*entity.Relation
requests map[int64]*entity.Request requests map[int64]*entity.Request
byPair map[requestKey]int64 byPair map[requestKey]int64
remarks map[remarkKey]string
blocks map[blockKey]time.Time
reports []*entity.Report reports []*entity.Report
} }
func NewMemoryRepo() *MemoryRepo { func NewMemoryRepo() *MemoryRepo {
return &MemoryRepo{ return &MemoryRepo{
relations: make(map[pairKey]*entity.Relation), relations: make(map[edgeKey]*entity.Relation),
requests: make(map[int64]*entity.Request), requests: make(map[int64]*entity.Request),
byPair: make(map[requestKey]int64), 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() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
rel, ok := m.relations[pairOf(a, b)] rel, ok := m.relations[edgeKey{UserID: userID, FriendID: friendID}]
if !ok { if !ok {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
@ -68,12 +54,17 @@ func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([]
limit = entity.ClampLimit(limit) limit = entity.ClampLimit(limit)
out := make([]*entity.Relation, 0) out := make([]*entity.Relation, 0)
for _, rel := range m.relations { for _, rel := range m.relations {
if rel.UserSmall == userID || rel.UserBig == userID { if rel.UserID == userID && rel.Status == entity.StatusFriend {
cp := *rel cp := *rel
out = append(out, &cp) 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 { if len(out) > limit {
out = out[:limit] out = out[:limit]
} }
@ -125,7 +116,7 @@ func (m *MemoryRepo) UpsertRequest(_ context.Context, req *entity.Request) error
return nil 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() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
limit = entity.ClampLimit(limit) limit = entity.ClampLimit(limit)
@ -147,7 +138,15 @@ func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, lim
return out, nil return out, nil
} }
func pairOf(a, b int64) pairKey { func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) {
small, big := entity.OrderUsers(a, b) for _, req := range m.requests {
return pairKey{Small: small, Big: big} 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" "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 { 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 { if userID <= 0 || friendID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
key := remarkKey{UserID: userID, FriendID: friendID} rel, ok := m.relations[edgeKey{UserID: userID, FriendID: friendID}]
if remark == "" { if !ok || rel.Status != entity.StatusFriend {
delete(m.remarks, key) return errs.ErrNotFound
return nil
} }
m.remarks[key] = remark fn(rel)
return nil return nil
} }
func (m *MemoryRepo) IsBlocked(_ context.Context, userID, targetID int64) (bool, error) { func (m *MemoryRepo) IsBlocked(_ context.Context, userID, targetID int64) (bool, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
_, ok := m.blocks[blockKey{UserID: userID, BlockedID: targetID}] rel, ok := m.relations[edgeKey{UserID: userID, FriendID: targetID}]
return ok, nil return ok && rel.Blocked, nil
} }
func (m *MemoryRepo) BlockedEither(_ context.Context, a, b int64) (bool, error) { func (m *MemoryRepo) BlockedEither(_ context.Context, a, b int64) (bool, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
_, ab := m.blocks[blockKey{UserID: a, BlockedID: b}] ab := m.relations[edgeKey{UserID: a, FriendID: b}]
_, ba := m.blocks[blockKey{UserID: b, BlockedID: a}] ba := m.relations[edgeKey{UserID: b, FriendID: a}]
return ab || ba, nil return (ab != nil && ab.Blocked) || (ba != nil && ba.Blocked), nil
} }
func (m *MemoryRepo) CreateBlock(_ context.Context, b *entity.Block) error { func (m *MemoryRepo) SetBlocked(_ context.Context, userID, friendID int64, blocked bool) error {
if b == nil || b.UserID <= 0 || b.BlockedID <= 0 { if userID <= 0 || friendID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
now := b.CreatedAt now := time.Now()
if now.IsZero() { key := edgeKey{UserID: userID, FriendID: friendID}
now = time.Now() rel, ok := m.relations[key]
if !ok {
if !blocked {
return nil
}
m.relations[key] = &entity.Relation{
UserID: userID, FriendID: friendID, Status: entity.StatusNone,
Blocked: true, CreatedAt: now, UpdatedAt: now,
}
m.rejectPendingLocked(userID, friendID, now)
return nil
}
rel.Blocked = blocked
rel.UpdatedAt = now
if blocked {
m.rejectPendingLocked(userID, friendID, now)
} }
m.blocks[blockKey{UserID: b.UserID, BlockedID: b.BlockedID}] = now
delete(m.relations, pairOf(b.UserID, b.BlockedID))
delete(m.remarks, remarkKey{UserID: b.UserID, FriendID: b.BlockedID})
delete(m.remarks, remarkKey{UserID: b.BlockedID, FriendID: b.UserID})
m.rejectPendingLocked(b.UserID, b.BlockedID, now)
return nil
}
func (m *MemoryRepo) DeleteBlock(_ context.Context, userID, blockedID int64) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.blocks, blockKey{UserID: userID, BlockedID: blockedID})
return nil 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 { func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) error {
if in.Relation == nil { if len(in.Edges) == 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
now := in.Relation.CreatedAt now := time.Now()
if now.IsZero() { for _, e := range in.Edges {
now = time.Now() if e == nil || e.UserID <= 0 || e.FriendID <= 0 {
in.Relation.CreatedAt = now return errs.ErrInvalidArgument
} }
key := pairKey{Small: in.Relation.UserSmall, Big: in.Relation.UserBig} if e.CreatedAt.IsZero() {
if _, ok := m.relations[key]; !ok { e.CreatedAt = now
cp := *in.Relation }
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 m.relations[key] = &cp
} }
for _, id := range in.Requests { for _, id := range in.Requests {
@ -114,38 +128,18 @@ func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) err
req.UpdatedAt = now 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 return nil
} }
func (m *MemoryRepo) DeleteFriend(_ context.Context, a, b int64) error { func (m *MemoryRepo) DeleteFriend(_ context.Context, a, b int64) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
key := pairOf(a, b) _, ab := m.relations[edgeKey{UserID: a, FriendID: b}]
if _, ok := m.relations[key]; !ok { _, ba := m.relations[edgeKey{UserID: b, FriendID: a}]
if !ab && !ba {
return errs.ErrNotFound return errs.ErrNotFound
} }
delete(m.relations, key) delete(m.relations, edgeKey{UserID: a, FriendID: b})
delete(m.remarks, remarkKey{UserID: a, FriendID: b}) delete(m.relations, edgeKey{UserID: b, FriendID: a})
delete(m.remarks, remarkKey{UserID: b, FriendID: a})
return nil 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,27 +2,37 @@
package entity package entity
const ( const (
StatusActive = 1 StatusActive Status = 1
StatusDissolved = 2 StatusDissolved Status = 2
MaxNameLen = 32 MaxNameLen = 32
MaxMembers = 200 MaxAnnouncementLen = 1024
ListDefault = 20 MaxRemarkLen = 32
ListMax = 100 MaxMembers = 200
MemberListMax = 200 ListDefault = 20
MaxCollageFaces = 9 ListMax = 100
MemberListMax = 200
MaxCollageFaces = 9
) )
// Status is persisted as groups.status.
type Status int
func (s Status) Valid() bool {
return s == StatusActive || s == StatusDissolved
}
// Group is a type-2 chat. ID is the AUTO_INCREMENT 群号; ConversationID is Snowflake. // Group is a type-2 chat. ID is the AUTO_INCREMENT 群号; ConversationID is Snowflake.
type Group struct { type Group struct {
ID int64 ID int64
ConversationID int64 ConversationID int64
Name string Name string
Avatar string Avatar string
Announcement string
OwnerID int64 OwnerID int64
MutedAll bool MutedAll bool
MemberCount int MemberCount int
Status int Status Status
CreatedAtMS int64 CreatedAtMS int64
UpdatedAtMS int64 UpdatedAtMS int64
} }

View File

@ -1,29 +1,51 @@
package entity package entity
const ( const (
RoleOwner = 1 RoleOwner Role = 1
RoleAdmin = 2 RoleAdmin Role = 2
RoleMember = 3 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 免打扰. // Member is a row in group_members. SpeakMuted is send-ban, not 免打扰.
type Member struct { type Member struct {
GroupID int64 GroupID int64
UserID int64 UserID int64
Role int Role Role
Nickname string Nickname string
Remark string
SpeakMuted bool SpeakMuted bool
JoinedAtMS int64 JoinedAtMS int64
} }
func ValidRole(role int) bool { func ValidRole(role int) bool {
return role == RoleOwner || role == RoleAdmin || role == RoleMember return Role(role).Valid()
} }
func (m *Member) CanManage() bool { func (m *Member) CanManage() bool {
return m != nil && (m.Role == RoleOwner || m.Role == RoleAdmin) 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 { func (m *Member) CanSpeak(mutedAll bool) bool {
if m == nil || m.SpeakMuted { if m == nil || m.SpeakMuted {
return false return false

View File

@ -47,7 +47,7 @@ func (s *Service) Create(ctx context.Context, ownerID int64, in *CreateInput) (*
s.invalidateMembers(g.ConversationID) s.invalidateMembers(g.ConversationID)
s.stampAvatar(ctx, g, ownerID, invitees) s.stampAvatar(ctx, g, ownerID, invitees)
_ = s.notice(ctx, g, ownerID, entity.SystemKindCreated, 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) { 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 { if err != nil {
return nil, err 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) { 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)) items := make([]*View, 0, len(groups))
for _, g := range groups { for _, g := range groups {
role := 0 mem, _ := s.repo.GetMember(ctx, g.ID, userID)
if mem, err := s.repo.GetMember(ctx, g.ID, userID); err == nil { items = append(items, viewOf(g, mem))
role = mem.Role
}
items = append(items, viewOf(g, role))
} }
return &List{Items: items}, nil return &List{Items: items}, nil
} }

View File

@ -106,6 +106,10 @@ func TestCreateGroupAutoIncrementID(t *testing.T) {
if err != nil || len(ids) != 3 { if err != nil || len(ids) != 3 {
t.Fatalf("members %v %v", ids, err) 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) { 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...) n.kv[key] = append([]byte(nil), body...)
return nil 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) _ = s.notice(ctx, g, actorID, entity.SystemKindInvite, ids)
mem, err := s.requireMember(ctx, groupID, actorID) mem, err := s.requireMember(ctx, groupID, actorID)
if err != nil { 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) { 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 { 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"` SpeakMuted *bool `json:"speak_muted"`
Nickname *string `json:"nickname" validate:"omitempty,max=32" label:"field.group_nickname" errmsg:"max:validate.group.nickname_invalid"` 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) { 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 { if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err 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 return nil, errs.ErrInvalidArgument
} }
g, err := s.loadActive(ctx, groupID) 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 { func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member, in *UpdateMemberInput) error {
lang := reqctx.Lang(ctx) lang := reqctx.Lang(ctx)
self := actorID == target.UserID 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 in.Nickname != nil {
if !self { if !self {
if _, err := s.requireManage(ctx, g.ID, actorID); err != nil { 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 target.Role = *in.Role
} }
if in.SpeakMuted != nil { 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 return err
} }
if target.Role == entity.RoleOwner { if !actor.CanModerate(target) {
return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil)) return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil))
} }
target.SpeakMuted = *in.SpeakMuted target.SpeakMuted = *in.SpeakMuted

View File

@ -54,6 +54,17 @@ func (s *Service) MemberIDs(ctx context.Context, conversationID int64) ([]int64,
return ids, nil 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 { func (s *Service) CanSend(ctx context.Context, userID, conversationID int64) error {
if !s.ready() || userID <= 0 || conversationID <= 0 { if !s.ready() || userID <= 0 || conversationID <= 0 {
return errs.ErrForbidden 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 return err
} }
msg := &msgentity.Message{ 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), 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(), Status: msgentity.StatusNormal, CreatedAtMS: now.UnixMilli(),
} }
if err := s.msgs.Create(ctx, msg); err != nil { 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, FromUserID: actorID, ToUserIDs: to,
Payload: reply.MustMarshal(reply.OK(groupPushBody{ Payload: reply.MustMarshal(reply.OK(groupPushBody{
MsgID: msgID, ConversationID: g.ConversationID, SenderID: 0, 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"` MsgID int64 `json:"msg_id"`
ConversationID int64 `json:"conversation_id"` ConversationID int64 `json:"conversation_id"`
SenderID int64 `json:"sender_id"` SenderID int64 `json:"sender_id"`
MessageType int16 `json:"message_type"` MessageType msgentity.Type `json:"message_type"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
CreatedAt int64 `json:"created_at"` 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 { if err != nil {
return err 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)) return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil))
} }
if err := s.repo.RemoveMember(ctx, g.ID, target.UserID); err != 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 g.OwnerID = in.UID
s.invalidateMembers(g.ConversationID) s.invalidateMembers(g.ConversationID)
_ = s.notice(ctx, g, ownerID, entity.SystemKindTransfer, []int64{in.UID}) _ = 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 { func (s *Service) Dissolve(ctx context.Context, ownerID, groupID int64) error {

View File

@ -10,9 +10,10 @@ import (
) )
type UpdateInput struct { 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"` 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"` Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar"`
MutedAll *bool `json:"muted_all"` Announcement *string `json:"announcement" validate:"omitempty,max=1024" label:"field.group_announcement" errmsg:"max:validate.group.announcement_invalid"`
MutedAll *bool `json:"muted_all"`
} }
func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateInput) (*View, error) { func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateInput) (*View, error) {
@ -25,7 +26,7 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err 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 return nil, errs.ErrInvalidArgument
} }
g, err := s.loadActive(ctx, groupID) 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 g.Avatar = av
} }
if in.Announcement != nil {
g.Announcement = strings.TrimSpace(*in.Announcement)
}
if in.MutedAll != nil { if in.MutedAll != nil {
g.MutedAll = *in.MutedAll g.MutedAll = *in.MutedAll
} }
@ -56,5 +60,5 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
if err != nil { if err != nil {
return nil, err return nil, err
} }
return viewOf(g, mem.Role), nil return viewOf(g, mem), nil
} }

View File

@ -9,27 +9,30 @@ import (
// View is the HTTP/TCP group card. ID is the AUTO_INCREMENT 群号. // View is the HTTP/TCP group card. ID is the AUTO_INCREMENT 群号.
type View struct { type View struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"` ConversationID int64 `json:"conversation_id"`
Name string `json:"name"` Name string `json:"name"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
OwnerID int64 `json:"owner_id"` Announcement string `json:"announcement"`
MutedAll bool `json:"muted_all"` OwnerID int64 `json:"owner_id"`
MemberCount int `json:"member_count"` MutedAll bool `json:"muted_all"`
Role int `json:"role"` MemberCount int `json:"member_count"`
CreatedAt int64 `json:"created_at"` Role entity.Role `json:"role"`
Remark string `json:"remark"`
GroupNickname string `json:"group_nickname"`
CreatedAt int64 `json:"created_at"`
} }
type MemberView struct { type MemberView struct {
UID int64 `json:"uid"` UID int64 `json:"uid"`
SparkID string `json:"spark_id"` SparkID string `json:"spark_id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Gender int `json:"gender"` Gender userentity.Gender `json:"gender"`
Role int `json:"role"` Role entity.Role `json:"role"`
GroupNickname string `json:"group_nickname"` GroupNickname string `json:"group_nickname"`
SpeakMuted bool `json:"speak_muted"` SpeakMuted bool `json:"speak_muted"`
JoinedAt int64 `json:"joined_at"` JoinedAt int64 `json:"joined_at"`
} }
type List struct { type List struct {
@ -40,15 +43,21 @@ type MemberList struct {
Items []*MemberView `json:"items"` Items []*MemberView `json:"items"`
} }
func viewOf(g *entity.Group, role int) *View { func viewOf(g *entity.Group, mem *entity.Member) *View {
if g == nil { if g == nil {
return nil return nil
} }
return &View{ v := &View{
ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar,
OwnerID: g.OwnerID, MutedAll: g.MutedAll, MemberCount: g.MemberCount, Announcement: g.Announcement, OwnerID: g.OwnerID, MutedAll: g.MutedAll,
Role: role, CreatedAt: g.CreatedAtMS, 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 { 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),发消息与同步用"` ConversationID int64 `gorm:"column:conversation_id;uniqueIndex;comment:对应 conversations.id(Snowflake),发消息与同步用"`
Name string `gorm:"column:name;comment:群名称"` Name string `gorm:"column:name;comment:群名称"`
Avatar string `gorm:"column:avatar;comment:群头像对象存储 key,空表示未设置"` Avatar string `gorm:"column:avatar;comment:群头像对象存储 key,空表示未设置"`
Announcement string `gorm:"column:announcement;comment:群公告,仅群主/管理员可改"`
OwnerID int64 `gorm:"column:owner_id;comment:群主 user_id"` OwnerID int64 `gorm:"column:owner_id;comment:群主 user_id"`
MutedAll int `gorm:"column:muted_all;comment:0=未全员禁言 1=全员禁言(群主/管理员仍可发言)"` MutedAll int `gorm:"column:muted_all;comment:0=未全员禁言 1=全员禁言(群主/管理员仍可发言)"`
MemberCount int `gorm:"column:member_count;comment:当前成员数,含群主"` MemberCount int `gorm:"column:member_count;comment:当前成员数,含群主"`
@ -29,8 +30,8 @@ func (r *Group) entity() *entity.Group {
return nil return nil
} }
g := &entity.Group{ g := &entity.Group{
ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, OwnerID: r.OwnerID, ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, Announcement: r.Announcement, OwnerID: r.OwnerID,
MemberCount: r.MemberCount, Status: r.Status, MemberCount: r.MemberCount, Status: entity.Status(r.Status),
CreatedAtMS: r.CreatedAt.UnixMilli(), UpdatedAtMS: r.UpdatedAt.UnixMilli(), CreatedAtMS: r.CreatedAt.UnixMilli(), UpdatedAtMS: r.UpdatedAt.UnixMilli(),
} }
g.MutedAll = r.MutedAll != 0 g.MutedAll = r.MutedAll != 0
@ -44,15 +45,15 @@ func groupRow(g *entity.Group) *Group {
updated = now updated = now
} }
row := &Group{ row := &Group{
ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, OwnerID: g.OwnerID, ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, Announcement: g.Announcement, OwnerID: g.OwnerID,
MemberCount: g.MemberCount, Status: g.Status, MemberCount: g.MemberCount, Status: int(g.Status),
CreatedAt: now, UpdatedAt: updated, CreatedAt: now, UpdatedAt: updated,
} }
if g.MutedAll { if g.MutedAll {
row.MutedAll = 1 row.MutedAll = 1
} }
if row.Status == 0 { if row.Status == 0 {
row.Status = entity.StatusActive row.Status = int(entity.StatusActive)
} }
return row return row
} }
@ -120,10 +121,11 @@ func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
return err return err
} }
res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{ res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{
"name": g.Name, "name": g.Name,
"avatar": g.Avatar, "avatar": g.Avatar,
"muted_all": boolInt(g.MutedAll), "announcement": g.Announcement,
"updated_at": msTime(g.UpdatedAtMS), "muted_all": boolInt(g.MutedAll),
"updated_at": msTime(g.UpdatedAtMS),
}) })
if res.Error != nil { if res.Error != nil {
return mapDBErr(res.Error) return mapDBErr(res.Error)

View File

@ -14,6 +14,7 @@ type GroupMember struct {
UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"` UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"`
Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"` Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"`
Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"` Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"`
Remark string `gorm:"column:remark;comment:成员对该群的备注,仅自己可见"`
SpeakMuted int `gorm:"column:speak_muted;comment:0=未禁言 1=被单独禁言"` SpeakMuted int `gorm:"column:speak_muted;comment:0=未禁言 1=被单独禁言"`
JoinedAt time.Time `gorm:"column:joined_at;comment:入群时间"` JoinedAt time.Time `gorm:"column:joined_at;comment:入群时间"`
} }
@ -25,8 +26,8 @@ func (r *GroupMember) entity() *entity.Member {
return nil return nil
} }
m := &entity.Member{ m := &entity.Member{
GroupID: r.GroupID, UserID: r.UserID, Role: r.Role, GroupID: r.GroupID, UserID: r.UserID, Role: entity.Role(r.Role),
Nickname: r.Nickname, JoinedAtMS: r.JoinedAt.UnixMilli(), Nickname: r.Nickname, Remark: r.Remark, JoinedAtMS: r.JoinedAt.UnixMilli(),
} }
m.SpeakMuted = r.SpeakMuted != 0 m.SpeakMuted = r.SpeakMuted != 0
return m return m
@ -34,14 +35,14 @@ func (r *GroupMember) entity() *entity.Member {
func memberRow(m *entity.Member) *GroupMember { func memberRow(m *entity.Member) *GroupMember {
row := &GroupMember{ row := &GroupMember{
GroupID: m.GroupID, UserID: m.UserID, Role: m.Role, GroupID: m.GroupID, UserID: m.UserID, Role: int(m.Role),
Nickname: m.Nickname, JoinedAt: msTime(m.JoinedAtMS), Nickname: m.Nickname, Remark: m.Remark, JoinedAt: msTime(m.JoinedAtMS),
} }
if m.SpeakMuted { if m.SpeakMuted {
row.SpeakMuted = 1 row.SpeakMuted = 1
} }
if row.Role == 0 { if row.Role == 0 {
row.Role = entity.RoleMember row.Role = int(entity.RoleMember)
} }
return row 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{ res := db.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", mem.GroupID, mem.UserID).Updates(map[string]any{
"role": mem.Role, "role": mem.Role,
"nickname": mem.Nickname, "nickname": mem.Nickname,
"remark": mem.Remark,
"speak_muted": boolInt(mem.SpeakMuted), "speak_muted": boolInt(mem.SpeakMuted),
}) })
if res.Error != nil { 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.Name = g.Name
cur.Avatar = g.Avatar cur.Avatar = g.Avatar
cur.Announcement = g.Announcement
cur.MutedAll = g.MutedAll cur.MutedAll = g.MutedAll
cur.UpdatedAtMS = g.UpdatedAtMS cur.UpdatedAtMS = g.UpdatedAtMS
if cur.UpdatedAtMS <= 0 { if cur.UpdatedAtMS <= 0 {
@ -164,6 +165,7 @@ func (m *MemoryRepo) UpdateMember(_ context.Context, mem *entity.Member) error {
} }
cur.Role = mem.Role cur.Role = mem.Role
cur.Nickname = mem.Nickname cur.Nickname = mem.Nickname
cur.Remark = mem.Remark
cur.SpeakMuted = mem.SpeakMuted cur.SpeakMuted = mem.SpeakMuted
return nil return nil
} }

View File

@ -23,7 +23,7 @@ func (m *Repo) CreateGroup(ctx context.Context, g *entity.Group, members []*enti
now := msTime(g.CreatedAtMS) now := msTime(g.CreatedAtMS)
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
conv := convmodel.Conversation{ 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 { if err := tx.Create(&conv).Error; err != nil {
return mapDBErr(err) return mapDBErr(err)

View File

@ -1,4 +1,3 @@
// Package entity holds inbox preview types. List/unread live in Redis, not MySQL.
package entity package entity
type Preview struct { type Preview struct {
@ -6,3 +5,17 @@ type Preview struct {
MsgID int64 MsgID int64
Snippet []byte 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 is the Redis inbox (conversation list) contract. No MySQL on this hot path.
package logic package logic
import "context" import (
"context"
"spark-server/internal/module/inbox/entity"
)
type Store interface { type Store interface {
TouchUnread(ctx context.Context, userID, conversationID int64, score int64) error TouchUnread(ctx context.Context, userID, conversationID int64, score int64) error
TouchRead(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 IncrUnread(ctx context.Context, userID, conversationID int64, delta int64) error
SetPreview(ctx context.Context, conversationID int64, preview []byte) 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. // Content is the in-memory message body. Persist only Marshal() bytes into messages.content.
type Content struct { type Content struct {
Type Type `json:"t"` 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"` Text *Text `json:"text,omitempty"`
Image *Image `json:"image,omitempty"` Image *Image `json:"image,omitempty"`
Voice *Voice `json:"voice,omitempty"` Voice *Voice `json:"voice,omitempty"`
@ -101,6 +104,12 @@ func (c *Content) Validate() error {
if !c.Type.Valid() { if !c.Type.Valid() {
return fmt.Errorf("content: unknown type %d", c.Type) 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() got, n := c.populated()
if n != 1 { if n != 1 {
return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n) 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 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 { func validateMediaKey(kind, key string) error {
if strings.TrimSpace(key) == "" { if strings.TrimSpace(key) == "" {
return fmt.Errorf("content: %s key empty", kind) 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" { if got.Type != TypeText || got.Text == nil || got.Text.Body != "hello" {
t.Fatalf("%+v", got) 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) { 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) { func TestObjectKeysAndReplace(t *testing.T) {
img := NewImage(&Image{Key: "chat/a.jpg", ThumbKey: "chat/t.jpg"}) img := NewImage(&Image{Key: "chat/a.jpg", ThumbKey: "chat/t.jpg"})
keys := img.ObjectKeys() keys := img.ObjectKeys()

View File

@ -2,15 +2,30 @@ package entity
import "fmt" 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. // Message is a persisted IM body. Content is serialized JSON bytes, not a string.
type Message struct { type Message struct {
ID int64 ID int64
ConversationID int64 ConversationID int64
SenderID int64 SenderID int64
ReceiverID int64 // private_message only; 0 for system
GroupID int64 // group_message only; groups.id 群号
ClientMsgID string ClientMsgID string
Type Type Type Type
Kind Kind
Content []byte Content []byte
Status int Status Status
CreatedAtMS int64 CreatedAtMS int64
} }

View File

@ -21,10 +21,20 @@ func (t Type) Valid() bool {
} }
const ( const (
StatusNormal = 1 StatusNormal Status = 1
StatusRecalled = 2 StatusRecalled Status = 2
StatusHidden = 3 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). // MaxContentBytes is the serialized content size cap (matches TCP Frame max).
const MaxContentBytes = 65536 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 package logic
import ( import (
"context" "context"
"encoding/json"
"time"
deliverentity "spark-server/internal/module/deliver/entity"
"spark-server/internal/module/message/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 { const cmdMessage uint16 = 6
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)
}
type SendRequest struct { type SendInput struct {
ConversationID int64 ConversationID int64 `json:"conversation_id"`
SenderID int64 PeerID int64 `json:"peer_id"`
DeviceID uint64 ClientMsgID string `json:"client_msg_id" validate:"required,max=64" label:"field.client_msg_id" errmsg:"required:validate.message.client_msg_required"`
ClientMsgID string MsgID int64 `json:"msg_id"`
MsgID int64 Content json.RawMessage `json:"content" validate:"required" label:"field.content" errmsg:"required:validate.message.content_required"`
Content *entity.Content
} }
type SendResponse struct { type SendResponse struct {
MsgID int64 MsgID int64 `json:"msg_id"`
ClientMsgID string ConversationID int64 `json:"conversation_id"`
ServerTimeMS int64 ClientMsgID string `json:"client_msg_id"`
ServerTimeMS int64 `json:"server_time"`
} }
// Service owns persist + enqueue. SEND_ACK happens only after both succeed. func (s *Service) SendMessage(ctx context.Context, userID int64, deviceID uint64, in *SendInput) (*SendResponse, error) {
type Service interface { if !s.ready() || userID <= 0 {
GenerateMsgID(ctx context.Context) (int64, error) return nil, errs.ErrUnauthorized
SendMessage(ctx context.Context, req *SendRequest) (*SendResponse, error) }
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" "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. // MemoryRepo is an in-process message store for tests and empty-DSN bring-up.
type MemoryRepo struct { type MemoryRepo struct {
mu sync.Mutex mu sync.Mutex
byID map[int64]*entity.Message byID map[int64]*entity.Message
byClient map[clientKey]int64
hidden map[hideKey]struct{}
} }
func NewMemoryRepo() *MemoryRepo { 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 { 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 { if message == nil || message.ID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
key := clientKey{SenderID: message.SenderID, ConversationID: message.ConversationID, ClientMsgID: message.ClientMsgID}
if _, ok := m.byID[message.ID]; ok { if _, ok := m.byID[message.ID]; ok {
return errs.ErrDuplicate return errs.ErrDuplicate
} }
cp := *message if message.ClientMsgID != "" {
if cp.Content != nil { if _, ok := m.byClient[key]; ok {
cp.Content = append([]byte(nil), message.Content...) return errs.ErrDuplicate
}
m.byClient[key] = message.ID
} }
m.byID[message.ID] = &cp m.byID[message.ID] = m.clone(message)
return nil return nil
} }
@ -42,11 +78,61 @@ func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.Message, erro
if !ok { if !ok {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
cp := *row return m.clone(row), nil
if cp.Content != nil { }
cp.Content = append([]byte(nil), row.Content...)
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 { func (m *MemoryRepo) All() []*entity.Message {
@ -54,11 +140,17 @@ func (m *MemoryRepo) All() []*entity.Message {
defer m.mu.Unlock() defer m.mu.Unlock()
out := make([]*entity.Message, 0, len(m.byID)) out := make([]*entity.Message, 0, len(m.byID))
for _, row := range m.byID { for _, row := range m.byID {
cp := *row out = append(out, m.clone(row))
if cp.Content != nil {
cp.Content = append([]byte(nil), row.Content...)
}
out = append(out, &cp)
} }
return out 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