feat: 私聊 群聊 群管理 上传
This commit is contained in:
parent
ae9a71034d
commit
8aa8e0bad9
@ -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`
|
||||||
|
|||||||
95
.cursor/skills/spark-im-v3/enums.md
Normal file
95
.cursor/skills/spark-im-v3/enums.md
Normal 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.
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
6
deploy/nginx/host/00-spark-http.conf
Normal file
6
deploy/nginx/host/00-spark-http.conf
Normal 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;
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 再压缩(阈值可配)。
|
||||||
|
|||||||
126
docs/clients/SparkImClient.java
Normal file
126
docs/clients/SparkImClient.java
Normal 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() {}
|
||||||
|
}
|
||||||
121
docs/clients/SparkImClient.swift
Normal file
121
docs/clients/SparkImClient.swift
Normal 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>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
26
internal/infrastructure/id/id_test.go
Normal file
26
internal/infrastructure/id/id_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
86
internal/module/deliver/logic/fanout.go
Normal file
86
internal/module/deliver/logic/fanout.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/module/deliver/logic/fanout_test.go
Normal file
72
internal/module/deliver/logic/fanout_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
package entity
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Block is unidirectional: UserID blocked BlockedID.
|
|
||||||
type Block struct {
|
|
||||||
UserID int64
|
|
||||||
BlockedID int64
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ 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"`
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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) {
|
if errors.Is(err, errs.ErrNotFound) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if !ab.Active() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
ba, err := s.repo.GetRelation(ctx, b, a)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errs.ErrNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return ba.Active(), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
25
internal/module/friend/logic/pin.go
Normal file
25
internal/module/friend/logic/pin.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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"
|
||||||
@ -14,13 +13,15 @@ type UserCard struct {
|
|||||||
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"`
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt int64 `json:"created_at,omitempty"`
|
CreatedAt int64 `json:"created_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +39,21 @@ func cardOf(u *userentity.User) UserCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func viewOf(u *userentity.User, rel *entity.Relation) *FriendView {
|
||||||
|
v := &FriendView{UserCard: cardOf(u), Relation: entity.RelationNone}
|
||||||
|
if rel == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
v.Remark = rel.Remark
|
||||||
|
v.Blocked = rel.Blocked
|
||||||
|
v.Pinned = rel.Pinned
|
||||||
|
v.Relation = entity.ViewRelation(rel.Active(), rel.Blocked)
|
||||||
|
if rel.Active() {
|
||||||
|
v.CreatedAt = entity.UnixMS(rel.CreatedAt)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) {
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
if errors.Is(err, errs.ErrNotFound) {
|
|
||||||
return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil))
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !ok {
|
||||||
|
return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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{
|
||||||
|
"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)
|
return mapDBErr(err)
|
||||||
}
|
}
|
||||||
if err := markRequestsAccepted(tx, in.Requests, now); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return upsertRemarks(tx, in.Remarks)
|
return markRequestsAccepted(tx, in.Requests, now)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,15 +60,13 @@ func (m *Repo) DeleteFriend(ctx context.Context, a, b int64) error {
|
|||||||
if err != nil {
|
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 deleteRemarksBetween(tx, a, b)
|
return nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
m.blocks[blockKey{UserID: b.UserID, BlockedID: b.BlockedID}] = now
|
if !blocked {
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
m.relations[key] = &entity.Relation{
|
||||||
func (m *MemoryRepo) DeleteBlock(_ context.Context, userID, blockedID int64) error {
|
UserID: userID, FriendID: friendID, Status: entity.StatusNone,
|
||||||
m.mu.Lock()
|
Blocked: true, CreatedAt: now, UpdatedAt: now,
|
||||||
defer m.mu.Unlock()
|
}
|
||||||
delete(m.blocks, blockKey{UserID: userID, BlockedID: blockedID})
|
m.rejectPendingLocked(userID, friendID, now)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel.Blocked = blocked
|
||||||
|
rel.UpdatedAt = now
|
||||||
|
if blocked {
|
||||||
|
m.rejectPendingLocked(userID, friendID, now)
|
||||||
|
}
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -2,10 +2,12 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusActive = 1
|
StatusActive Status = 1
|
||||||
StatusDissolved = 2
|
StatusDissolved Status = 2
|
||||||
|
|
||||||
MaxNameLen = 32
|
MaxNameLen = 32
|
||||||
|
MaxAnnouncementLen = 1024
|
||||||
|
MaxRemarkLen = 32
|
||||||
MaxMembers = 200
|
MaxMembers = 200
|
||||||
ListDefault = 20
|
ListDefault = 20
|
||||||
ListMax = 100
|
ListMax = 100
|
||||||
@ -13,16 +15,24 @@ const (
|
|||||||
MaxCollageFaces = 9
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
48
internal/module/group/logic/mention.go
Normal file
48
internal/module/group/logic/mention.go
Normal 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
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ 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"`
|
||||||
|
Announcement *string `json:"announcement" validate:"omitempty,max=1024" label:"field.group_announcement" errmsg:"max:validate.group.announcement_invalid"`
|
||||||
MutedAll *bool `json:"muted_all"`
|
MutedAll *bool `json:"muted_all"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
|
|||||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,13 @@ type View struct {
|
|||||||
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"`
|
||||||
|
Announcement string `json:"announcement"`
|
||||||
OwnerID int64 `json:"owner_id"`
|
OwnerID int64 `json:"owner_id"`
|
||||||
MutedAll bool `json:"muted_all"`
|
MutedAll bool `json:"muted_all"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
Role int `json:"role"`
|
Role entity.Role `json:"role"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
GroupNickname string `json:"group_nickname"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,8 +28,8 @@ type MemberView struct {
|
|||||||
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"`
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
@ -122,6 +123,7 @@ func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
|
|||||||
res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{
|
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,
|
||||||
|
"announcement": g.Announcement,
|
||||||
"muted_all": boolInt(g.MutedAll),
|
"muted_all": boolInt(g.MutedAll),
|
||||||
"updated_at": msTime(g.UpdatedAtMS),
|
"updated_at": msTime(g.UpdatedAtMS),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
122
internal/module/inbox/model/memory.go
Normal file
122
internal/module/inbox/model/memory.go
Normal 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]
|
||||||
|
}
|
||||||
130
internal/module/inbox/model/redis.go
Normal file
130
internal/module/inbox/model/redis.go
Normal 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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
46
internal/module/message/logic/delete.go
Normal file
46
internal/module/message/logic/delete.go
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
51
internal/module/message/logic/history.go
Normal file
51
internal/module/message/logic/history.go
Normal 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
|
||||||
|
}
|
||||||
256
internal/module/message/logic/message_test.go
Normal file
256
internal/module/message/logic/message_test.go
Normal 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
|
||||||
|
}
|
||||||
68
internal/module/message/logic/recall.go
Normal file
68
internal/module/message/logic/recall.go
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
139
internal/module/message/logic/send_perm.go
Normal file
139
internal/module/message/logic/send_perm.go
Normal 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
|
||||||
|
}
|
||||||
45
internal/module/message/logic/send_persist.go
Normal file
45
internal/module/message/logic/send_persist.go
Normal 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
|
||||||
|
}
|
||||||
74
internal/module/message/logic/service.go
Normal file
74
internal/module/message/logic/service.go
Normal 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
|
||||||
|
}
|
||||||
54
internal/module/message/logic/view.go
Normal file
54
internal/module/message/logic/view.go
Normal 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
|
||||||
|
}
|
||||||
20
internal/module/message/model/group_message.go
Normal file
20
internal/module/message/model/group_message.go
Normal 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
|
||||||
|
}
|
||||||
@ -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.byID[message.ID] = &cp
|
m.byClient[key] = message.ID
|
||||||
|
}
|
||||||
|
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...)
|
|
||||||
}
|
}
|
||||||
return &cp, nil
|
|
||||||
|
func (m *MemoryRepo) GetByClientMsgID(_ context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
id, ok := m.byClient[clientKey{SenderID: senderID, ConversationID: conversationID, ClientMsgID: clientMsgID}]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.ErrNotFound
|
||||||
|
}
|
||||||
|
return 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--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
13
internal/module/message/model/message_delete.go
Normal file
13
internal/module/message/model/message_delete.go
Normal 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" }
|
||||||
20
internal/module/message/model/private_message.go
Normal file
20
internal/module/message/model/private_message.go
Normal 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
|
||||||
|
}
|
||||||
112
internal/module/message/model/query.go
Normal file
112
internal/module/message/model/query.go
Normal 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
Loading…
Reference in New Issue
Block a user