feat: 私聊 群聊 群管理 上传
This commit is contained in:
parent
ae9a71034d
commit
8aa8e0bad9
@ -6,7 +6,8 @@ description: >-
|
||||
handlers, go-playground validator, RealIP, messages, deliver queue, inbox
|
||||
ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, MsgId,
|
||||
TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP, validator, LoginService,
|
||||
i18n, Accept-Language, zh-CN, uid, spark_id, infrastructure, entity, model, logic.
|
||||
i18n, Accept-Language, zh-CN, uid, spark_id, infrastructure, entity, model, logic,
|
||||
enum, MessageType, oneof.
|
||||
---
|
||||
|
||||
# Spark IM V3
|
||||
@ -30,6 +31,7 @@ internal/infrastructure process adapters (mysql, redis, id, logger, pus
|
||||
|
||||
HTTP: read [http.md](http.md) — validator keys, `reqctx.RealIP`, `/v1/{resource}` groups, MVC login.
|
||||
i18n: read [i18n.md](i18n.md) — request language, error `msg`, notification types.
|
||||
Enums: read [enums.md](enums.md) — typed entity enums, OpenAPI `$ref`, `oneof` bound to the same numbers.
|
||||
|
||||
## Layers
|
||||
|
||||
@ -61,7 +63,7 @@ Not every PK is Snowflake. Do not AUTO_INCREMENT natural keys.
|
||||
| Kind | Examples | Rule |
|
||||
|------|----------|------|
|
||||
| Snowflake | `users.id` (uid), `messages.id` (MsgId), `conversations.id` | Time-ordered business IDs. One `id.Generator` per process; `snowflake_worker_id` unique per process. Clock rewind fails issuance for the whole process. |
|
||||
| AUTO_INCREMENT | `user_devices.id`, `groups.id` (群号) | Internal row id, or sequential public 群号. Business chat cursor is still Snowflake `conversations.id`. |
|
||||
| AUTO_INCREMENT | `user_devices.id`, `groups.id` (群号), `friend_relations.id` | Internal row id, or sequential public 群号. Friend edges are directed (`user_id`→`friend_id`); UNIQUE `(user_id, friend_id)`. Business chat cursor is still Snowflake `conversations.id`. |
|
||||
| Natural key | `user_emails.email`, P2P `(user_small, user_big)`, members, sync cursors | Composite / natural PK. No extra surrogate id. |
|
||||
|
||||
`device_id` is **client-generated**, stored on the device, written to JWT `did`. The server does not allocate it. `uid` in JSON **is** `users.id`; do not add a second uid column.
|
||||
@ -90,10 +92,10 @@ If step 5 fails, do not ACK. Client retries the same `client_msg_id`.
|
||||
`AUTH HEARTBEAT GENERATE_MSG_ID SEND_MESSAGE SEND_ACK MESSAGE PUSH_ACK READ RECALL SYNC_INBOX SYNC_MESSAGES KICK`
|
||||
|
||||
Media: HTTP upload credential, message content is object key only. Frame max 64KB. Compress body over 512B.
|
||||
Message types: text / image / voice / video / emoji / gif / file / location / system. `messages.content` stores serialized JSON bytes (BLOB), never a raw string.
|
||||
Message types: text / image / voice / video / emoji / gif / file / location / system (`message/entity.Type` 1–9). `messages.content` stores serialized JSON bytes (BLOB), never a raw string. Closed type/status values: [enums.md](enums.md).
|
||||
|
||||
TCP body is JSON envelope `{code,msg,data}`. AUTH request is `{"token","device_id","app_version","language"}` (not protobuf).
|
||||
Public TLS is Nginx `:443` SNI: HTTP cert on loopback `:8443`, IM cert on loopback `:9001` then PROXY to gnet `:9000`. See `deploy/nginx/`.
|
||||
TCP body is JSON envelope `{code,msg,request_id,data}`. AUTH request is `{"token","device_id","app_version","language"}` (not protobuf).
|
||||
Public TLS is Nginx `:443` SNI: HTTP cert on loopback `:8443`, IM cert on loopback `:19001` then PROXY to gnet `:9090` (host `:9000` is rustfs). See `deploy/nginx/`.
|
||||
AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Deliver workers start with `RequeueStale` + `SETNX deliver:done:{msgId}`.
|
||||
|
||||
## When adding a feature
|
||||
@ -102,6 +104,7 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del
|
||||
- List/unread go to Redis ZSET, not MySQL row-per-member.
|
||||
- New goroutine I/O does not belong in `OnTraffic`.
|
||||
- HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context.
|
||||
- Closed type/status: entity typed enum + `Valid()` + OpenAPI named `$ref` + validator `oneof` on the same values ([enums.md](enums.md)).
|
||||
- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`).
|
||||
- SQL tables and GORM models carry COMMENT on the table and every column.
|
||||
- After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy.
|
||||
@ -113,4 +116,5 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del
|
||||
- [send-path.md](send-path.md)
|
||||
- [http.md](http.md)
|
||||
- [i18n.md](i18n.md)
|
||||
- [enums.md](enums.md)
|
||||
- `docs/architecture-v3.md`
|
||||
|
||||
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 |
|
||||
| Controller | `internal/transport/http/handler/{biz}` | Bind, call service, write envelope |
|
||||
| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,data}` |
|
||||
| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,request_id,data}` |
|
||||
|
||||
Handlers must not contain business rules (credential check, token issue, friend/group permission, persist). Example: login belongs on `auth/logic.LoginService`, not in the Gin handler.
|
||||
|
||||
@ -78,21 +78,22 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans
|
||||
|
||||
| Group | Prefix | Examples |
|
||||
|-------|--------|----------|
|
||||
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI |
|
||||
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) |
|
||||
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
|
||||
| users | `/v1/users` | `GET/PATCH /v1/users/me` |
|
||||
| friends | `/v1/friends` | apply, requests, accept, profile, remark, block, report |
|
||||
| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report |
|
||||
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
|
||||
| conversations | `/v1/conversations` | list, mute |
|
||||
| messages | `/v1/messages` | history (sync still prefers TCP) |
|
||||
| files | `/v1/files` | upload credential |
|
||||
| meta | `/v1/meta` | `GET /v1/meta/enums`(无需登录) |
|
||||
|
||||
Rules:
|
||||
|
||||
- Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP.
|
||||
- Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`.
|
||||
- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs).
|
||||
- OpenAPI `tags` must match these groups.
|
||||
- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`).
|
||||
- OpenAPI `tags` must match these groups. Docs jump tags `enums` / `errors` sit above `system` and open `/docs/enums`、`/docs/errors`.
|
||||
|
||||
## Checklist when adding an HTTP API
|
||||
|
||||
@ -103,3 +104,4 @@ Rules:
|
||||
5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`.
|
||||
6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit.
|
||||
7. Localize errors/notifications via `internal/pkg/i18n`; never hardcode `msg`.
|
||||
8. Closed type/status: entity typed enum + OpenAPI named schema `$ref` ([enums.md](enums.md)). Do not inline a second `enum:` list.
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
| C1 | MySQL + Snowflake MsgId. No ConversationSeq. |
|
||||
| C2 | EventLoop ≠ MySQL. Fanout = Redis list + worker (`BRPOPLPUSH`). |
|
||||
| C3 | TLS + thin Frame. No custom handshake crypto. |
|
||||
| C4 | Public 443 / Nginx SNI. gnet is `127.0.0.1:9000`. |
|
||||
| C4 | Public 443 / Nginx SNI. gnet/Gin bind loopback only. |
|
||||
| C5 | ACK = COMMIT + enqueue. |
|
||||
| C6 | PROXY RealIP for limit/audit. |
|
||||
| C7 | Dialog list in Redis ZSET. Mailbox only for missed push + control events. |
|
||||
|
||||
@ -5,7 +5,7 @@ run:
|
||||
tests: true
|
||||
go: "1.26"
|
||||
|
||||
linters_settings:
|
||||
linters-settings:
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 100
|
||||
@ -87,6 +87,7 @@ issues:
|
||||
- dupl
|
||||
- funlen
|
||||
- goconst
|
||||
- path: internal/module/.*/entity\.go
|
||||
- path: internal/pkg/i18n/catalog_
|
||||
linters:
|
||||
- unused
|
||||
- dupl
|
||||
- goconst
|
||||
|
||||
@ -18,7 +18,7 @@ COPY migrations /app/migrations
|
||||
COPY deploy/docker/config.docker.yaml /app/configs/config.yaml
|
||||
RUN mkdir -p /app/logs && chown -R spark:spark /app
|
||||
USER spark
|
||||
EXPOSE 8080 9000
|
||||
EXPOSE 8080 9090
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/spark-server"]
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
Spark IM modular monolith. Architecture: [docs/architecture-v3.md](docs/architecture-v3.md).
|
||||
|
||||
- Go 1.26
|
||||
- Gin HTTP on `127.0.0.1:8080`. JSON envelope `{"code","msg","data"}`. Docs: `GET /docs`, spec: `GET /openapi.yaml`.
|
||||
- Gin HTTP on `127.0.0.1:8080`. JSON envelope `{"code","msg","request_id","data"}`. Docs: `GET /docs` (API), `/docs/enums`, `/docs/errors`. Spec: `GET /openapi.yaml`.
|
||||
- Probes: `GET /healthz` liveness; `GET /readyz` readiness (HTTP 503 when MySQL/Redis/task pool/accepting are not ready). `env: prod` (or `SPARK_ENV=prod`) rejects empty MySQL DSN, empty Redis, and the default JWT secret.
|
||||
- Logs: zap to console and/or rotating file (`log.console` / `log.file`). GORM SQL goes through the same zap. `trace_id` / `request_id` follow context (`X-Request-ID` / `X-Trace-ID`).
|
||||
- gnet TCP on `127.0.0.1:9000` (EventLoop + TaskPool). gnet v2.10 has no built-in TLS; Nginx terminates IM TLS and forwards PROXY+plaintext. Sample: `deploy/nginx/`.
|
||||
- TCP Frame body is JSON `{code,msg,data}`. AUTH body: `{"token","device_id","app_version","language"}`.
|
||||
- Logs: zap to console and/or rotating file (`log.console` / `log.file`). GORM SQL goes through the same zap. `request_id` is server-generated and returned in the response body (also as `X-Request-ID`).
|
||||
- gnet TCP on loopback (local `127.0.0.1:8090`; ECS Docker `127.0.0.1:9090` because host `:9000` is rustfs). gnet v2.10 has no built-in TLS; Nginx terminates IM TLS and forwards PROXY+plaintext. Sample: `deploy/nginx/`. `make deploy` writes image/config/logs under `/data/spark-server/`.
|
||||
- TCP Frame body is JSON `{code,msg,request_id,data}`. AUTH body: `{"token","device_id","app_version","language"}`.
|
||||
- Public entry is Nginx `:443` only (SNI split)
|
||||
|
||||
```bash
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"spark-server/internal/bootstrap"
|
||||
"spark-server/internal/infrastructure/mysql"
|
||||
@ -74,6 +75,10 @@ func run(cfgPath, dir string) error {
|
||||
gdb := db.Gorm()
|
||||
ctx := context.Background()
|
||||
for _, path := range files {
|
||||
if skipFriendDirected(gdb, path) {
|
||||
fmt.Printf("skip %s\n", filepath.Base(path))
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -120,10 +125,21 @@ func ignorableDDL(err error) bool {
|
||||
}
|
||||
switch me.Number {
|
||||
case 1050, // table already exists
|
||||
1051, // unknown table
|
||||
1060, // duplicate column
|
||||
1061: // duplicate key name
|
||||
1061, // duplicate key name
|
||||
1146: // table doesn't exist
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func skipFriendDirected(gdb *gorm.DB, path string) bool {
|
||||
if filepath.Base(path) != "0013_friend_directed.sql" || gdb == nil {
|
||||
return false
|
||||
}
|
||||
var n int64
|
||||
err := gdb.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'friend_relations' AND column_name = 'user_id'`).Scan(&n).Error
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ log:
|
||||
compress: true
|
||||
|
||||
mysql:
|
||||
dsn: ""
|
||||
dsn: "spark:zpQxymHedkYrr8dK@tcp(47.236.66.9:3307)/spark?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
slow_threshold: 200ms
|
||||
log_level: ""
|
||||
max_open_conns: 64
|
||||
@ -41,9 +41,9 @@ mysql:
|
||||
conn_max_idle_time: 10m
|
||||
|
||||
redis:
|
||||
addr: ""
|
||||
addr: "47.236.66.9:6379"
|
||||
username: ""
|
||||
password: ""
|
||||
password: "kL4co8TEaE68vnMk"
|
||||
db: 0
|
||||
deliver_queue_key: "deliver:queue"
|
||||
deliver_processing_key: "deliver:processing"
|
||||
@ -83,9 +83,9 @@ message:
|
||||
msgid_expire_ms: 300000
|
||||
|
||||
object_storage:
|
||||
endpoint: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
endpoint: "47.236.66.9:9000"
|
||||
access_key: "rustfs_spark"
|
||||
secret_key: 'kWvGtYT/~q+35q;3dSI<^P!MH+bgrpBg'
|
||||
bucket: "spark"
|
||||
region: "us-east-1"
|
||||
use_ssl: false
|
||||
|
||||
@ -4,7 +4,7 @@ server:
|
||||
http:
|
||||
addr: "0.0.0.0:8080"
|
||||
tcp:
|
||||
addr: "0.0.0.0:9000"
|
||||
addr: "0.0.0.0:9090"
|
||||
proxy_protocol: true
|
||||
tls:
|
||||
cert_file: ""
|
||||
@ -32,7 +32,7 @@ log:
|
||||
compress: true
|
||||
|
||||
mysql:
|
||||
dsn: "spark:@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
dsn: "spark:zpQxymHedkYrr8dK@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
slow_threshold: 200ms
|
||||
log_level: ""
|
||||
max_open_conns: 64
|
||||
@ -50,7 +50,7 @@ redis:
|
||||
deliver_done_prefix: "deliver:done:"
|
||||
|
||||
jwt:
|
||||
secret: "dev-only-change-me"
|
||||
secret: "0e624dce0ebe9115c2eb048646a141ccbd88366e7f2c2718065bc3a3838dda95"
|
||||
issuer: "spark-im"
|
||||
ttl: 168h
|
||||
refresh_ttl: 720h
|
||||
@ -84,8 +84,8 @@ message:
|
||||
|
||||
object_storage:
|
||||
endpoint: "rustfs:9000"
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
access_key: "rustfs_spark"
|
||||
secret_key: 'kWvGtYT/~q+35q;3dSI<^P!MH+bgrpBg'
|
||||
bucket: "spark"
|
||||
region: "us-east-1"
|
||||
use_ssl: false
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy spark-server to the ECS host and join mysql/redis/rustfs on shell_default.
|
||||
# Host layout:
|
||||
# /data/spark-server/app source (docker build context)
|
||||
# /data/spark-server/configs config.yaml
|
||||
# /data/spark-server/logs process logs
|
||||
# /data/spark-server/images docker save of spark-server:latest
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
|
||||
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}"
|
||||
REMOTE="${SPARK_REMOTE_DIR:-/opt/spark-server}"
|
||||
DATA="${SPARK_DATA_DIR:-/data/spark-server}"
|
||||
REMOTE="${SPARK_REMOTE_DIR:-$DATA/app}"
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST")
|
||||
|
||||
"${SSH[@]}" "mkdir -p $DATA/app $DATA/configs $DATA/logs $DATA/images"
|
||||
|
||||
rsync -az --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude 'bin/' \
|
||||
@ -20,9 +28,11 @@ rsync -az --delete \
|
||||
-e "ssh -i $KEY -o BatchMode=yes" \
|
||||
"$ROOT/" "$HOST:$REMOTE/"
|
||||
|
||||
"${SSH[@]}" "mkdir -p /data/spark-server/logs && chown -R 10001:10001 /data/spark-server/logs"
|
||||
"${SSH[@]}" "cp $REMOTE/deploy/docker/config.docker.yaml $DATA/configs/config.yaml && chown -R 10001:10001 $DATA/logs"
|
||||
"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml build"
|
||||
"${SSH[@]}" "docker save spark-server:latest | gzip -c > $DATA/images/spark-server.tar.gz"
|
||||
"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml up -d spark-server"
|
||||
"${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations"
|
||||
"${SSH[@]}" "cp $REMOTE/deploy/nginx/host/spark-sni.conf /etc/nginx/stream.d/spark-sni.conf && nginx -t && systemctl reload nginx"
|
||||
"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
# Joins the existing mysql/redis/rustfs network (docker compose project "shell").
|
||||
# Host :9000 is rustfs S3. gnet is loopback :9090.
|
||||
# Image/config/logs live under /data/spark-server on the ECS host.
|
||||
services:
|
||||
spark-server:
|
||||
build:
|
||||
@ -11,10 +13,10 @@ services:
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "127.0.0.1:18080:8080"
|
||||
- "127.0.0.1:19000:9000"
|
||||
- "127.0.0.1:9090:9090"
|
||||
volumes:
|
||||
- /data/spark-server/logs:/app/logs
|
||||
- ./config.docker.yaml:/app/configs/config.yaml:ro
|
||||
- /data/spark-server/configs/config.yaml:/app/configs/config.yaml:ro
|
||||
networks:
|
||||
- shell
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ Client --TLS--> :443 ssl_preread
|
||||
├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
|
||||
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
|
||||
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
|
||||
→ PROXY protocol → 127.0.0.1:19000 gnet
|
||||
→ PROXY protocol → 127.0.0.1:9090 gnet
|
||||
```
|
||||
|
||||
On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/etc/nginx/nginx.conf`. Drop-ins:
|
||||
@ -15,12 +15,13 @@ On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/
|
||||
- `host/sparkapi.conf` → `/etc/nginx/conf.d/`
|
||||
- `host/spark-sni.conf` → `/etc/nginx/stream.d/`
|
||||
|
||||
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443 and `$remote_addr` stays the client IP.
|
||||
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443. `00-spark-http.conf` sets `real_ip_header proxy_protocol` and `port_in_redirect off` so `$remote_addr` is the client and redirects stay on 443.
|
||||
|
||||
spark-server (Docker):
|
||||
|
||||
- HTTP `127.0.0.1:18080` → container `:8080`
|
||||
- TCP `127.0.0.1:19000` with `proxy_protocol: true` → container `:9000`
|
||||
- TCP `127.0.0.1:9090` with `proxy_protocol: true` → container `:9090` (host `:9000` is rustfs S3)
|
||||
- Image / config / logs: `/data/spark-server/{images,configs,logs}`
|
||||
- Public security group: 443 only
|
||||
|
||||
```bash
|
||||
|
||||
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).
|
||||
# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :19000
|
||||
# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :9090
|
||||
# default SNI → existing HTTP sites on 127.0.0.1:8443 (proxy_protocol restores RealIP)
|
||||
|
||||
map $ssl_preread_server_name $spark_sni_backend {
|
||||
@ -9,7 +9,7 @@ map $ssl_preread_server_name $spark_sni_backend {
|
||||
|
||||
server {
|
||||
listen 443 reuseport;
|
||||
listen [::]:443 reuseport;
|
||||
listen [::]:443 reuseport ipv6only=on;
|
||||
ssl_preread on;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_timeout 3600s;
|
||||
@ -25,5 +25,5 @@ server {
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_timeout 3600s;
|
||||
proxy_protocol on;
|
||||
proxy_pass 127.0.0.1:19000;
|
||||
proxy_pass 127.0.0.1:9090;
|
||||
}
|
||||
|
||||
@ -48,8 +48,15 @@ rewrite_http_443() {
|
||||
install_sni() {
|
||||
rewrite_http_443
|
||||
rm -f /etc/nginx/conf.d/spark-acme.conf
|
||||
cp "$HOST_DIR/00-spark-http.conf" /etc/nginx/conf.d/00-spark-http.conf
|
||||
cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
|
||||
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf
|
||||
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
|
||||
#!/bin/sh
|
||||
systemctl reload nginx
|
||||
HOOK
|
||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
if grep -RInE 'listen[[:space:]]+(\[::\]:)?443[[:space:]]+ssl' /etc/nginx/conf.d; then
|
||||
echo "http still binds public 443" >&2
|
||||
exit 1
|
||||
|
||||
@ -289,7 +289,7 @@ TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope
|
||||
```text
|
||||
Magic u16 | Version u8 | Command u16 | Flags u16
|
||||
RequestID u64 | SequenceID u32 | BodyLength u32
|
||||
JSON Body {"code","msg","data"}
|
||||
JSON Body {"code","msg","request_id","data"}
|
||||
```
|
||||
|
||||
Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。
|
||||
|
||||
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"
|
||||
grouplogic "spark-server/internal/module/group/logic"
|
||||
groupmodel "spark-server/internal/module/group/model"
|
||||
inboxlogic "spark-server/internal/module/inbox/logic"
|
||||
inboxmodel "spark-server/internal/module/inbox/model"
|
||||
msglogic "spark-server/internal/module/message/logic"
|
||||
msgmodel "spark-server/internal/module/message/model"
|
||||
synclogic "spark-server/internal/module/sync/logic"
|
||||
syncmodel "spark-server/internal/module/sync/model"
|
||||
uploadlogic "spark-server/internal/module/upload/logic"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
@ -154,11 +159,15 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
login := authlogic.NewLoginService(jwtSvc, users)
|
||||
login.Sessions = sessions
|
||||
var deliverQueue deliverlogic.Queue
|
||||
app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions)
|
||||
friends := wireFriends(idgen, db, users, deliverQueue)
|
||||
groups := wireGroups(idgen, db, users, friends, deliverQueue, objects)
|
||||
inbox, mailbox := wireInbox(idgen, db, rdb)
|
||||
app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions, inbox, mailbox)
|
||||
syncSvc := synclogic.NewService(inbox, mailbox)
|
||||
chat := wireChat(idgen, db)
|
||||
friends := wireFriends(idgen, db, users, deliverQueue, chat)
|
||||
groups := wireGroups(idgen, db, users, friends, deliverQueue, objects, chat)
|
||||
messages := wireMessages(idgen, friends, groups, deliverQueue, chat)
|
||||
files := wireFiles(cfg, objects, idgen, groups)
|
||||
favorites := wireFavorites(idgen, db, objects)
|
||||
favorites := wireFavorites(idgen, db, objects, chat)
|
||||
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
||||
Addr: cfg.Server.TCP.Addr,
|
||||
Multicore: cfg.Server.TCP.Multicore,
|
||||
@ -175,6 +184,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
IDs: idgen,
|
||||
Auth: jwtSvc,
|
||||
AuthLimiter: authLimit,
|
||||
Messages: messages,
|
||||
Sync: syncSvc,
|
||||
})
|
||||
app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{
|
||||
Auth: jwtSvc,
|
||||
@ -184,6 +195,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
Groups: groups,
|
||||
Files: files,
|
||||
Favorites: favorites,
|
||||
Messages: messages,
|
||||
Sync: syncSvc,
|
||||
Health: probe,
|
||||
AuthLimiter: authLimit,
|
||||
})
|
||||
@ -221,37 +234,46 @@ func wireUsers(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client, store *obj
|
||||
return userlogic.NewService(repo, codes, idgen, objects)
|
||||
}
|
||||
|
||||
func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue) *friendlogic.Service {
|
||||
var repo friendlogic.Repository
|
||||
var chats friendlogic.P2PChats
|
||||
var msgs friendlogic.Messages
|
||||
if db != nil && db.Ready() {
|
||||
repo = friendmodel.NewRepo(db.Gorm())
|
||||
chats = convlogic.NewP2PService(convmodel.NewRepo(db.Gorm()), idgen)
|
||||
msgs = msgmodel.NewRepo(db.Gorm())
|
||||
} else {
|
||||
repo = friendmodel.NewMemoryRepo()
|
||||
chats = convlogic.NewP2PService(convmodel.NewMemoryP2P(), idgen)
|
||||
msgs = msgmodel.NewMemoryRepo()
|
||||
}
|
||||
return friendlogic.NewService(repo, users, idgen, friendlogic.NewGreeter(chats, msgs, idgen, q))
|
||||
type chatStores struct {
|
||||
msgs msglogic.Repository
|
||||
chats msglogic.Conversations
|
||||
p2p *convlogic.P2PService
|
||||
}
|
||||
|
||||
func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, friends *friendlogic.Service, q grouplogic.DeliverQueue, store *objectstore.Store) *grouplogic.Service {
|
||||
func wireChat(idgen *id.Generator, db *mysql.DB) chatStores {
|
||||
if idgen == nil {
|
||||
return chatStores{}
|
||||
}
|
||||
if db != nil && db.Ready() {
|
||||
convRepo := convmodel.NewRepo(db.Gorm())
|
||||
return chatStores{msgs: msgmodel.NewRepo(db.Gorm()), chats: convRepo, p2p: convlogic.NewP2PService(convRepo, idgen)}
|
||||
}
|
||||
mem := convmodel.NewMemoryP2P()
|
||||
return chatStores{msgs: msgmodel.NewMemoryRepo(), chats: mem, p2p: convlogic.NewP2PService(mem, idgen)}
|
||||
}
|
||||
|
||||
func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue, chat chatStores) *friendlogic.Service {
|
||||
var repo friendlogic.Repository
|
||||
if db != nil && db.Ready() {
|
||||
repo = friendmodel.NewRepo(db.Gorm())
|
||||
} else {
|
||||
repo = friendmodel.NewMemoryRepo()
|
||||
}
|
||||
return friendlogic.NewService(repo, users, idgen, friendlogic.NewGreeter(chat.p2p, chat.msgs, idgen, q))
|
||||
}
|
||||
|
||||
func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, friends *friendlogic.Service, q grouplogic.DeliverQueue, store *objectstore.Store, chat chatStores) *grouplogic.Service {
|
||||
var repo grouplogic.Repository
|
||||
var msgs grouplogic.Messages
|
||||
if db != nil && db.Ready() {
|
||||
repo = groupmodel.NewRepo(db.Gorm())
|
||||
msgs = msgmodel.NewRepo(db.Gorm())
|
||||
} else {
|
||||
repo = groupmodel.NewMemoryRepo()
|
||||
msgs = msgmodel.NewMemoryRepo()
|
||||
}
|
||||
var objects grouplogic.Objects
|
||||
if store != nil && store.Ready() {
|
||||
objects = store
|
||||
}
|
||||
return grouplogic.NewService(repo, users, friends, idgen, msgs, q, objects)
|
||||
return grouplogic.NewService(repo, users, friends, idgen, chat.msgs, q, objects)
|
||||
}
|
||||
|
||||
func wireFiles(cfg *Config, store *objectstore.Store, idgen *id.Generator, groups *grouplogic.Service) *uploadlogic.Service {
|
||||
@ -265,30 +287,40 @@ func wireFiles(cfg *Config, store *objectstore.Store, idgen *id.Generator, group
|
||||
})
|
||||
}
|
||||
|
||||
func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store) *favlogic.Service {
|
||||
func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, chat chatStores) *favlogic.Service {
|
||||
if idgen == nil {
|
||||
return nil
|
||||
}
|
||||
var repo favlogic.Repository
|
||||
var msgs favlogic.Messages
|
||||
var members favlogic.Members
|
||||
if db != nil && db.Ready() {
|
||||
repo = favmodel.NewRepo(db.Gorm())
|
||||
msgs = msgmodel.NewRepo(db.Gorm())
|
||||
members = convmodel.NewRepo(db.Gorm())
|
||||
} else {
|
||||
repo = favmodel.NewMemoryRepo()
|
||||
msgs = msgmodel.NewMemoryRepo()
|
||||
members = convmodel.NewMemoryP2P()
|
||||
}
|
||||
var objects favlogic.Objects
|
||||
if store != nil && store.Ready() {
|
||||
objects = store
|
||||
}
|
||||
return favlogic.NewService(repo, msgs, members, objects, idgen)
|
||||
return favlogic.NewService(repo, chat.msgs, chat.chats, objects, idgen)
|
||||
}
|
||||
|
||||
func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions session.Manager) (*deliverlogic.Worker, deliverlogic.Queue) {
|
||||
func wireInbox(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (inboxlogic.Store, synclogic.Mailbox) {
|
||||
var inbox inboxlogic.Store
|
||||
if rdb != nil && rdb.Ready() {
|
||||
inbox = inboxmodel.NewRedisStore(rdb.RDB())
|
||||
} else {
|
||||
inbox = inboxmodel.NewMemoryStore()
|
||||
}
|
||||
var box synclogic.Mailbox
|
||||
if db != nil && db.Ready() {
|
||||
box = syncmodel.NewStore(db.Gorm(), idgen)
|
||||
} else {
|
||||
box = syncmodel.NewMemoryStore(idgen)
|
||||
}
|
||||
return inbox, box
|
||||
}
|
||||
|
||||
func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions session.Manager, inbox inboxlogic.Store, mailbox deliverlogic.Mailbox) (*deliverlogic.Worker, deliverlogic.Queue) {
|
||||
var q deliverlogic.Queue
|
||||
var done deliverlogic.Deduper
|
||||
if rdb != nil && rdb.Ready() {
|
||||
@ -301,10 +333,17 @@ func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions sess
|
||||
return deliverlogic.NewWorker(log, q, done, deliverlogic.WorkerOptions{
|
||||
Workers: cfg.Deliver.Workers,
|
||||
TakeTimeout: cfg.Deliver.TakeTimeout,
|
||||
Handler: deliverlogic.SessionFanout(sessions),
|
||||
Handler: deliverlogic.InboxFanout(sessions, inbox, mailbox),
|
||||
}), q
|
||||
}
|
||||
|
||||
func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *grouplogic.Service, q msglogic.DeliverQueue, chat chatStores) *msglogic.Service {
|
||||
if idgen == nil {
|
||||
return nil
|
||||
}
|
||||
return msglogic.NewService(chat.msgs, chat.chats, chat.p2p, friends, groups, idgen, q)
|
||||
}
|
||||
|
||||
func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
|
||||
if rdb != nil && rdb.Ready() {
|
||||
return authlogic.NewRedisDenylist(rdb.RDB(), prefix)
|
||||
|
||||
@ -44,3 +44,25 @@ func (g *Generator) NextID() (int64, error) {
|
||||
}
|
||||
|
||||
func (g *Generator) WorkerID() int64 { return g.workerID }
|
||||
|
||||
const DefaultMsgIDSkew = 5 * time.Minute
|
||||
|
||||
// IssuedAtMS is the Snowflake unix-ms timestamp encoded in id.
|
||||
func IssuedAtMS(id int64) int64 {
|
||||
if id <= 0 {
|
||||
return 0
|
||||
}
|
||||
return snowflake.ID(id).Time()
|
||||
}
|
||||
|
||||
// WithinSkew reports whether id's timestamp is within skew of now (SEND client MsgId ±5m).
|
||||
func WithinSkew(id int64, now time.Time, skew time.Duration) bool {
|
||||
if id <= 0 || skew <= 0 {
|
||||
return false
|
||||
}
|
||||
delta := now.UnixMilli() - IssuedAtMS(id)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
return delta <= skew.Milliseconds()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Attach stores a logger on ctx with trace_id / request_id from RequestContext.
|
||||
// Attach stores a logger on ctx with request_id from RequestContext.
|
||||
func Attach(ctx context.Context, base *zap.Logger) context.Context {
|
||||
if base == nil {
|
||||
base = zap.NewNop()
|
||||
@ -42,9 +42,6 @@ func WithReq(log *zap.Logger, rc *reqctx.RequestContext) *zap.Logger {
|
||||
return log
|
||||
}
|
||||
fields := make([]zap.Field, 0, 8)
|
||||
if rc.TraceID != "" {
|
||||
fields = append(fields, zap.String("trace_id", rc.TraceID))
|
||||
}
|
||||
if rc.RequestID != "" {
|
||||
fields = append(fields, zap.String("request_id", rc.RequestID))
|
||||
}
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
// Package entity holds conversation domain types.
|
||||
package entity
|
||||
|
||||
// Type is persisted as conversations.type.
|
||||
type Type int
|
||||
|
||||
const (
|
||||
TypeP2P = 1
|
||||
TypeGroup = 2
|
||||
TypeP2P Type = 1
|
||||
TypeGroup Type = 2
|
||||
)
|
||||
|
||||
func (t Type) Valid() bool {
|
||||
return t == TypeP2P || t == TypeGroup
|
||||
}
|
||||
|
||||
// Conversation is a P2P or group chat. LastMsgID is the Snowflake cursor, not ConversationSeq.
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
Type int
|
||||
Type Type
|
||||
LastMsgID int64
|
||||
LastMsgAt int64
|
||||
}
|
||||
|
||||
@ -31,6 +31,25 @@ func (r *ConversationMember) entity() *entity.Member {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Repo) ListMemberIDs(ctx context.Context, conversationID int64) ([]int64, error) {
|
||||
if conversationID <= 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []ConversationMember
|
||||
if err := db.Where("conversation_id = ?", conversationID).Find(&rows).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].UserID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (m *Repo) GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) {
|
||||
if conversationID <= 0 || userID <= 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
|
||||
@ -70,6 +70,31 @@ func (m *MemoryP2P) CreateP2P(_ context.Context, c *entity.Conversation, p2p *en
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) Get(_ context.Context, id int64) (*entity.Conversation, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
row, ok := m.byID[id]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
cp := *row
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) ListMemberIDs(_ context.Context, conversationID int64) ([]int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
byUser, ok := m.members[conversationID]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
ids := make([]int64, 0, len(byUser))
|
||||
for uid := range byUser {
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) GetMember(_ context.Context, conversationID, userID int64) (*entity.Member, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@ -77,7 +77,7 @@ func (m *Repo) CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entit
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
conv := Conversation{
|
||||
ID: c.ID, Type: c.Type, LastMsgID: c.LastMsgID,
|
||||
ID: c.ID, Type: int(c.Type), LastMsgID: c.LastMsgID,
|
||||
LastMsgAt: msTime(c.LastMsgAt), CreatedAt: msTime(c.LastMsgAt),
|
||||
}
|
||||
if err := tx.Create(&conv).Error; err != nil {
|
||||
@ -126,7 +126,7 @@ func (r *Conversation) entity() *entity.Conversation {
|
||||
return nil
|
||||
}
|
||||
return &entity.Conversation{
|
||||
ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(),
|
||||
ID: r.ID, Type: entity.Type(r.Type), LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ type Job struct {
|
||||
NotPush bool `json:"not_push,omitempty"`
|
||||
NotMailbox bool `json:"not_mailbox,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
Preview []byte `json:"preview,omitempty"`
|
||||
raw []byte `json:"-"`
|
||||
}
|
||||
|
||||
|
||||
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"`
|
||||
MsgID int64 `json:"msg_id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageType int16 `json:"message_type"`
|
||||
MessageType msgentity.Type `json:"message_type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
@ -112,6 +112,6 @@ func itemOf(fav *entity.Favorite) *Item {
|
||||
}
|
||||
return &Item{
|
||||
ID: fav.ID, MsgID: fav.MsgID, ConversationID: fav.ConversationID,
|
||||
MessageType: fav.MessageType, Content: fav.Content, CreatedAt: fav.CreatedAtMS,
|
||||
MessageType: msgentity.Type(fav.MessageType), Content: fav.Content, CreatedAt: fav.CreatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package entity
|
||||
|
||||
// AcceptParams is the transactional write when two users become friends.
|
||||
type AcceptParams struct {
|
||||
Relation *Relation
|
||||
Edges []*Relation
|
||||
Requests []int64
|
||||
Remarks []Remark
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
const (
|
||||
RelationNone = 0
|
||||
RelationFriend = 1
|
||||
RelationBlock = 2
|
||||
RelationNone RelationKind = 0
|
||||
RelationFriend RelationKind = 1
|
||||
RelationBlock RelationKind = 2
|
||||
|
||||
StatusNone EdgeStatus = 0
|
||||
StatusFriend EdgeStatus = 1
|
||||
)
|
||||
|
||||
// RelationKind is the viewer-centric JSON `relation` (not the table column).
|
||||
type RelationKind int
|
||||
|
||||
func (k RelationKind) Valid() bool {
|
||||
return k == RelationNone || k == RelationFriend || k == RelationBlock
|
||||
}
|
||||
|
||||
// EdgeStatus is persisted as friend_relations.status.
|
||||
type EdgeStatus int
|
||||
|
||||
func (s EdgeStatus) Valid() bool {
|
||||
return s == StatusNone || s == StatusFriend
|
||||
}
|
||||
|
||||
const (
|
||||
ListDefault = 20
|
||||
ListMax = 100
|
||||
)
|
||||
|
||||
// Relation is a bidirectional friendship. Natural key (user_small, user_big).
|
||||
// Relation is one directed edge: UserID's view of FriendID.
|
||||
type Relation struct {
|
||||
UserSmall int64
|
||||
UserBig int64
|
||||
ID int64
|
||||
UserID int64
|
||||
FriendID int64
|
||||
Status EdgeStatus
|
||||
Remark string
|
||||
Blocked bool
|
||||
Pinned bool
|
||||
Source Source
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewRelation(a, b int64, at time.Time) *Relation {
|
||||
small, big := OrderUsers(a, b)
|
||||
return &Relation{UserSmall: small, UserBig: big, CreatedAt: at}
|
||||
func (r *Relation) Active() bool {
|
||||
return r != nil && r.Status == StatusFriend
|
||||
}
|
||||
|
||||
func (r *Relation) Other(me int64) int64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
func NewFriendEdge(userID, friendID int64, remark string, source Source, at time.Time) *Relation {
|
||||
return &Relation{
|
||||
UserID: userID, FriendID: friendID, Status: StatusFriend,
|
||||
Remark: remark, Source: source, CreatedAt: at, UpdatedAt: at,
|
||||
}
|
||||
if r.UserSmall == me {
|
||||
return r.UserBig
|
||||
}
|
||||
if r.UserBig == me {
|
||||
return r.UserSmall
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ClampLimit(limit int) int {
|
||||
@ -48,8 +65,8 @@ func ClampLimit(limit int) int {
|
||||
return limit
|
||||
}
|
||||
|
||||
// ViewStatus is the viewer-centric relation: friend, blocked-by-me, or none.
|
||||
func ViewStatus(friend, blockedByMe bool) int {
|
||||
// ViewRelation is the viewer-centric relation: friend, blocked-by-me, or none.
|
||||
func ViewRelation(friend, blockedByMe bool) RelationKind {
|
||||
if blockedByMe {
|
||||
return RelationBlock
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
const (
|
||||
ReportSpam = 1
|
||||
ReportHarassment = 2
|
||||
ReportFake = 3
|
||||
ReportOther = 4
|
||||
ReportSpam ReportReason = 1
|
||||
ReportHarassment ReportReason = 2
|
||||
ReportFake ReportReason = 3
|
||||
ReportOther ReportReason = 4
|
||||
)
|
||||
|
||||
// ReportReason is persisted as friend_reports.reason.
|
||||
type ReportReason int
|
||||
|
||||
func (r ReportReason) Valid() bool {
|
||||
return r >= ReportSpam && r <= ReportOther
|
||||
}
|
||||
|
||||
const ReportDetailMax = 512
|
||||
|
||||
// Report is a complaint about TargetID by UserID.
|
||||
@ -16,11 +23,11 @@ type Report struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TargetID int64
|
||||
Reason int
|
||||
Reason ReportReason
|
||||
Detail string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func ValidReportReason(r int) bool {
|
||||
return r >= ReportSpam && r <= ReportOther
|
||||
return ReportReason(r).Valid()
|
||||
}
|
||||
|
||||
@ -3,17 +3,32 @@ package entity
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RequestPending = 1
|
||||
RequestAccepted = 2
|
||||
RequestRejected = 3
|
||||
RequestPending RequestStatus = 1
|
||||
RequestAccepted RequestStatus = 2
|
||||
RequestRejected RequestStatus = 3
|
||||
)
|
||||
|
||||
// RequestStatus is persisted as friend_requests.status and returned as apply `status`.
|
||||
type RequestStatus int
|
||||
|
||||
func (s RequestStatus) Valid() bool {
|
||||
return s == RequestPending || s == RequestAccepted || s == RequestRejected
|
||||
}
|
||||
|
||||
const (
|
||||
SourceQRCode = 1
|
||||
SourceEmail = 2
|
||||
SourceSparkID = 3
|
||||
SourceUnknown Source = 0
|
||||
SourceQRCode Source = 1
|
||||
SourceEmail Source = 2
|
||||
SourceSparkID Source = 3
|
||||
)
|
||||
|
||||
// Source is friend add origin on the request and the directed edge.
|
||||
type Source int
|
||||
|
||||
func (s Source) Valid() bool {
|
||||
return s == SourceUnknown || s == SourceQRCode || s == SourceEmail || s == SourceSparkID
|
||||
}
|
||||
|
||||
const (
|
||||
ReasonMax = 128
|
||||
RemarkMax = 64
|
||||
@ -24,14 +39,14 @@ type Request struct {
|
||||
ID int64
|
||||
FromUser int64
|
||||
ToUser int64
|
||||
Source int
|
||||
Source Source
|
||||
Reason string
|
||||
Remark string
|
||||
Status int
|
||||
Status RequestStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func ValidSource(src int) bool {
|
||||
return src == SourceQRCode || src == SourceEmail || src == SourceSparkID
|
||||
return Source(src).Valid() && Source(src) != SourceUnknown
|
||||
}
|
||||
|
||||
@ -51,19 +51,16 @@ func (s *Service) Accept(ctx context.Context, userID int64, in AcceptInput) (*Ac
|
||||
|
||||
func (s *Service) becomeFriends(ctx context.Context, req *entity.Request, accepter int64, accepterRemark string) error {
|
||||
now := time.Now()
|
||||
rel := entity.NewRelation(req.FromUser, req.ToUser, now)
|
||||
fromRemark := strings.TrimSpace(req.Remark)
|
||||
edges := []*entity.Relation{
|
||||
entity.NewFriendEdge(req.FromUser, req.ToUser, fromRemark, req.Source, now),
|
||||
entity.NewFriendEdge(accepter, req.FromUser, accepterRemark, req.Source, now),
|
||||
}
|
||||
ids := []int64{req.ID}
|
||||
if reverse, err := s.repo.GetRequestByPair(ctx, accepter, req.FromUser); err == nil && reverse.ID != req.ID {
|
||||
ids = append(ids, reverse.ID)
|
||||
}
|
||||
remarks := make([]entity.Remark, 0, 2)
|
||||
if strings.TrimSpace(req.Remark) != "" {
|
||||
remarks = append(remarks, entity.Remark{UserID: req.FromUser, FriendID: req.ToUser, Text: req.Remark, UpdatedAt: now})
|
||||
}
|
||||
if accepterRemark != "" {
|
||||
remarks = append(remarks, entity.Remark{UserID: accepter, FriendID: req.FromUser, Text: accepterRemark, UpdatedAt: now})
|
||||
}
|
||||
if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Relation: rel, Requests: ids, Remarks: remarks}); err != nil {
|
||||
if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Edges: edges, Requests: ids}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.greeter != nil {
|
||||
|
||||
@ -26,7 +26,7 @@ type ApplyInput struct {
|
||||
|
||||
// ApplyResult is returned after applying or auto-accepting a reverse request.
|
||||
type ApplyResult struct {
|
||||
Status string `json:"status"`
|
||||
Status entity.RequestStatus `json:"status"`
|
||||
RequestID int64 `json:"request_id,omitempty"`
|
||||
UID int64 `json:"uid"`
|
||||
}
|
||||
@ -52,7 +52,7 @@ func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (
|
||||
if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ApplyResult{Status: "accepted", UID: target.ID}, nil
|
||||
return &ApplyResult{Status: entity.RequestAccepted, UID: target.ID}, nil
|
||||
} else if err != nil && !errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
@ -60,10 +60,10 @@ func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ApplyResult{Status: "pending", RequestID: req.ID, UID: target.ID}, nil
|
||||
return &ApplyResult{Status: entity.RequestPending, RequestID: req.ID, UID: target.ID}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, int, error) {
|
||||
func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, entity.Source, error) {
|
||||
lang := reqctx.Lang(ctx)
|
||||
n := 0
|
||||
if in.UID > 0 {
|
||||
@ -101,7 +101,7 @@ func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentit
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source int) (*userentity.User, int, error) {
|
||||
func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source entity.Source) (*userentity.User, entity.Source, error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, 0, errs.ErrNotFound
|
||||
@ -144,15 +144,17 @@ func (s *Service) canApply(ctx context.Context, from, to int64) error {
|
||||
if blockedBy {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
if _, err := s.repo.GetRelation(ctx, from, to); err == nil {
|
||||
return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil))
|
||||
} else if !errors.Is(err, errs.ErrNotFound) {
|
||||
ok, err := s.AreFriends(ctx, from, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) upsertPending(ctx context.Context, from, to int64, source int, in *ApplyInput) (*entity.Request, error) {
|
||||
func (s *Service) upsertPending(ctx context.Context, from, to int64, source entity.Source, in *ApplyInput) (*entity.Request, error) {
|
||||
now := time.Now()
|
||||
req := &entity.Request{
|
||||
FromUser: from, ToUser: to, Source: source,
|
||||
|
||||
@ -3,38 +3,36 @@ package logic
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/friend/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
|
||||
func (s *Service) Block(ctx context.Context, userID, targetID int64) error {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
if targetID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
if entity.SameUser(userID, targetID) {
|
||||
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil))
|
||||
}
|
||||
if _, err := s.activeUser(ctx, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.CreateBlock(ctx, &entity.Block{UserID: userID, BlockedID: targetID, CreatedAt: time.Now()})
|
||||
return s.setBlocked(ctx, userID, targetID, true)
|
||||
}
|
||||
|
||||
func (s *Service) Unblock(ctx context.Context, userID, targetID int64) error {
|
||||
return s.setBlocked(ctx, userID, targetID, false)
|
||||
}
|
||||
|
||||
func (s *Service) setBlocked(ctx context.Context, userID, targetID int64, blocked bool) error {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
if targetID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
return s.repo.DeleteBlock(ctx, userID, targetID)
|
||||
if userID == targetID {
|
||||
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil))
|
||||
}
|
||||
if blocked {
|
||||
if _, err := s.activeUser(ctx, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.repo.SetBlocked(ctx, userID, targetID, blocked)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, userID, friendID int64) error {
|
||||
|
||||
@ -2,7 +2,6 @@ package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
@ -14,12 +13,13 @@ func (s *Service) CanSend(ctx context.Context, fromUserID, toUserID int64) error
|
||||
if fromUserID == toUserID {
|
||||
return errs.ErrForbidden
|
||||
}
|
||||
if _, err := s.repo.GetRelation(ctx, fromUserID, toUserID); err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return errs.ErrForbidden
|
||||
}
|
||||
ok, err := s.AreFriends(ctx, fromUserID, toUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errs.ErrForbidden
|
||||
}
|
||||
blocked, err := s.repo.BlockedEither(ctx, fromUserID, toUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -60,7 +60,7 @@ func TestApplyByEmailSparkIDQRAndDedupe(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
a, b := twoUsers(t, users)
|
||||
first, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Reason: "hi", Remark: "work"})
|
||||
if err != nil || first.Status != "pending" || first.RequestID == 0 {
|
||||
if err != nil || first.Status != entity.RequestPending || first.RequestID == 0 {
|
||||
t.Fatalf("email apply %+v %v", first, err)
|
||||
}
|
||||
second, err := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID, Reason: "again"})
|
||||
@ -119,7 +119,7 @@ func TestMutualApplyAutoAccept(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com", Remark: "b-note"})
|
||||
if err != nil || out.Status != "accepted" {
|
||||
if err != nil || out.Status != entity.RequestAccepted {
|
||||
t.Fatalf("%+v %v", out, err)
|
||||
}
|
||||
if g.n != 1 {
|
||||
@ -151,6 +151,14 @@ func TestNoUnidirectionalDeleteAndBlock(t *testing.T) {
|
||||
if err := svc.Block(ctx, a.ID, b.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := svc.AreFriends(ctx, a.ID, b.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("block keeps friendship %v %v", ok, err)
|
||||
}
|
||||
view, err := svc.Get(ctx, a.ID, b.ID)
|
||||
if err != nil || !view.Blocked || view.Relation != entity.RelationBlock {
|
||||
t.Fatalf("blocked view %+v %v", view, err)
|
||||
}
|
||||
if err := svc.CanSend(ctx, a.ID, b.ID); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("blocked send %v", err)
|
||||
}
|
||||
@ -162,6 +170,39 @@ func TestNoUnidirectionalDeleteAndBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinOrdersList(t *testing.T) {
|
||||
svc, users, _ := testFriends(t)
|
||||
ctx := t.Context()
|
||||
a, b := twoUsers(t, users)
|
||||
c, err := users.AuthenticateEmail(ctx, "test03@spark.com", "1", &userentity.LoginDevice{DeviceID: 3, DeviceType: userentity.DeviceIOS})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, peer := range []int64{b.ID, c.ID} {
|
||||
applied, err := svc.Apply(ctx, a.ID, &ApplyInput{UID: peer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Accept(ctx, peer, AcceptInput{RequestID: applied.RequestID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := svc.Pin(ctx, a.ID, c.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := svc.List(ctx, a.ID, 20)
|
||||
if err != nil || len(list.Items) != 2 || list.Items[0].UID != c.ID || !list.Items[0].Pinned {
|
||||
t.Fatalf("pinned first %+v %v", list, err)
|
||||
}
|
||||
if err := svc.Unpin(ctx, a.ID, c.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = svc.List(ctx, a.ID, 20)
|
||||
if err != nil || list.Items[0].Pinned {
|
||||
t.Fatalf("unpinned %+v %v", list, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemarkReportLookup(t *testing.T) {
|
||||
svc, users, _ := testFriends(t)
|
||||
ctx := t.Context()
|
||||
|
||||
@ -11,12 +11,22 @@ func (s *Service) AreFriends(ctx context.Context, a, b int64) (bool, error) {
|
||||
if !s.ready() || a <= 0 || b <= 0 || a == b {
|
||||
return false, nil
|
||||
}
|
||||
_, err := s.repo.GetRelation(ctx, a, b)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
ab, err := s.repo.GetRelation(ctx, a, b)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !ab.Active() {
|
||||
return false, nil
|
||||
}
|
||||
ba, err := s.repo.GetRelation(ctx, b, a)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return ba.Active(), nil
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@ func (g *SystemGreeter) BecameFriends(ctx context.Context, userA, userB int64) e
|
||||
SenderID: 0,
|
||||
ClientMsgID: fmt.Sprintf("sys:friend:%d", msgID),
|
||||
Type: msgentity.TypeSystem,
|
||||
Kind: msgentity.KindPrivate,
|
||||
Content: raw,
|
||||
Status: msgentity.StatusNormal,
|
||||
CreatedAtMS: now.UnixMilli(),
|
||||
@ -81,7 +82,7 @@ type pushBody struct {
|
||||
MsgID int64 `json:"msg_id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
MessageType int16 `json:"message_type"`
|
||||
MessageType msgentity.Type `json:"message_type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
@ -89,6 +90,6 @@ type pushBody struct {
|
||||
func pushBodyOf(m *msgentity.Message) pushBody {
|
||||
return pushBody{
|
||||
MsgID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID,
|
||||
MessageType: int16(m.Type), Content: m.Content, CreatedAt: m.CreatedAtMS,
|
||||
MessageType: m.Type, Content: m.Content, CreatedAt: m.CreatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,31 +33,25 @@ func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*Fr
|
||||
if blockedBy {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
friend := false
|
||||
if _, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil {
|
||||
friend = true
|
||||
} else if !errors.Is(err, errs.ErrNotFound) {
|
||||
rel, err := s.repo.GetRelation(ctx, userID, target.ID)
|
||||
if err != nil && !errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
blockedMe, err := s.repo.IsBlocked(ctx, userID, target.ID)
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
rel = nil
|
||||
}
|
||||
friend, err := s.AreFriends(ctx, userID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remark := ""
|
||||
if friend {
|
||||
remark, err = s.repo.GetRemark(ctx, userID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
view := &FriendView{
|
||||
UserCard: cardOf(target),
|
||||
Remark: remark,
|
||||
Relation: entity.ViewStatus(friend, blockedMe),
|
||||
}
|
||||
if friend {
|
||||
if rel, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil {
|
||||
view.CreatedAt = entity.UnixMS(rel.CreatedAt)
|
||||
blockedMe := rel != nil && rel.Blocked
|
||||
view := viewOf(target, rel)
|
||||
view.Relation = entity.ViewRelation(friend, blockedMe)
|
||||
if !friend {
|
||||
view.CreatedAt = 0
|
||||
view.Pinned = false
|
||||
if rel == nil {
|
||||
view.Remark = ""
|
||||
}
|
||||
}
|
||||
return view, nil
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"spark-server/internal/module/friend/entity"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
@ -14,13 +13,15 @@ type UserCard struct {
|
||||
SparkID string `json:"spark_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Gender int `json:"gender"`
|
||||
Gender userentity.Gender `json:"gender"`
|
||||
}
|
||||
|
||||
type FriendView struct {
|
||||
UserCard
|
||||
Remark string `json:"remark"`
|
||||
Relation int `json:"relation"`
|
||||
Relation entity.RelationKind `json:"relation"`
|
||||
Blocked bool `json:"blocked"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
@ -38,6 +39,21 @@ func cardOf(u *userentity.User) UserCard {
|
||||
}
|
||||
}
|
||||
|
||||
func viewOf(u *userentity.User, rel *entity.Relation) *FriendView {
|
||||
v := &FriendView{UserCard: cardOf(u), Relation: entity.RelationNone}
|
||||
if rel == nil {
|
||||
return v
|
||||
}
|
||||
v.Remark = rel.Remark
|
||||
v.Blocked = rel.Blocked
|
||||
v.Pinned = rel.Pinned
|
||||
v.Relation = entity.ViewRelation(rel.Active(), rel.Blocked)
|
||||
if rel.Active() {
|
||||
v.CreatedAt = entity.UnixMS(rel.CreatedAt)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
@ -46,29 +62,13 @@ func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendLis
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
if other := rel.Other(userID); other > 0 {
|
||||
ids = append(ids, other)
|
||||
}
|
||||
}
|
||||
notes, err := s.repo.ListRemarks(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*FriendView, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
other := rel.Other(userID)
|
||||
u, err := s.users.Get(ctx, other)
|
||||
u, err := s.users.Get(ctx, rel.FriendID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, &FriendView{
|
||||
UserCard: cardOf(u),
|
||||
Remark: notes[other],
|
||||
Relation: entity.RelationFriend,
|
||||
CreatedAt: entity.UnixMS(rel.CreatedAt),
|
||||
})
|
||||
items = append(items, viewOf(u, rel))
|
||||
}
|
||||
return &FriendList{Items: items}, nil
|
||||
}
|
||||
@ -82,23 +82,14 @@ func (s *Service) Get(ctx context.Context, userID, friendID int64) (*FriendView,
|
||||
}
|
||||
rel, err := s.repo.GetRelation(ctx, userID, friendID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !rel.Active() {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
u, err := s.activeUser(ctx, friendID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remark, err := s.repo.GetRemark(ctx, userID, friendID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &FriendView{
|
||||
UserCard: cardOf(u),
|
||||
Remark: remark,
|
||||
Relation: entity.RelationFriend,
|
||||
CreatedAt: entity.UnixMS(rel.CreatedAt),
|
||||
}, nil
|
||||
return viewOf(u, rel), nil
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
@ -32,12 +31,12 @@ func (s *Service) requireFriend(ctx context.Context, userID, friendID int64) err
|
||||
if friendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
_, err := s.repo.GetRelation(ctx, userID, friendID)
|
||||
if err == nil {
|
||||
return nil
|
||||
ok, err := s.AreFriends(ctx, userID, friendID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
if !ok {
|
||||
return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil))
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type ReportInput struct {
|
||||
Reason int `json:"reason" validate:"required,oneof=1 2 3 4" label:"field.report_reason" errmsg:"required:validate.friend.report_reason_required;oneof:validate.friend.report_reason_invalid"`
|
||||
Reason entity.ReportReason `json:"reason" validate:"required,oneof=1 2 3 4" label:"field.report_reason" errmsg:"required:validate.friend.report_reason_required;oneof:validate.friend.report_reason_invalid"`
|
||||
Detail string `json:"detail" validate:"omitempty,max=512" label:"field.report_detail" errmsg:"max:validate.friend.report_detail_invalid"`
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ type RequestView struct {
|
||||
ID int64 `json:"id"`
|
||||
From UserCard `json:"from"`
|
||||
Reason string `json:"reason"`
|
||||
Source int `json:"source"`
|
||||
Source entity.Source `json:"source"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
@ -10,19 +10,17 @@ import (
|
||||
|
||||
// Repository is friend persistence. Model implements it; do not split this by table.
|
||||
type Repository interface {
|
||||
GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error)
|
||||
GetRelation(ctx context.Context, userID, friendID int64) (*entity.Relation, error)
|
||||
ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error)
|
||||
GetRequest(ctx context.Context, id int64) (*entity.Request, error)
|
||||
GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error)
|
||||
UpsertRequest(ctx context.Context, req *entity.Request) error
|
||||
ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error)
|
||||
GetRemark(ctx context.Context, userID, friendID int64) (string, error)
|
||||
ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error)
|
||||
ListIncoming(ctx context.Context, toUserID int64, status entity.RequestStatus, limit int) ([]*entity.Request, error)
|
||||
SetRemark(ctx context.Context, userID, friendID int64, remark string) error
|
||||
SetPinned(ctx context.Context, userID, friendID int64, pinned bool) error
|
||||
SetBlocked(ctx context.Context, userID, friendID int64, blocked bool) error
|
||||
IsBlocked(ctx context.Context, userID, targetID int64) (bool, error)
|
||||
BlockedEither(ctx context.Context, a, b int64) (bool, error)
|
||||
CreateBlock(ctx context.Context, b *entity.Block) error
|
||||
DeleteBlock(ctx context.Context, userID, blockedID int64) error
|
||||
CreateReport(ctx context.Context, r *entity.Report) error
|
||||
AcceptFriend(ctx context.Context, in entity.AcceptParams) error
|
||||
DeleteFriend(ctx context.Context, a, b int64) error
|
||||
|
||||
@ -11,28 +11,47 @@ import (
|
||||
)
|
||||
|
||||
func (m *Repo) AcceptFriend(ctx context.Context, in entity.AcceptParams) error {
|
||||
if in.Relation == nil {
|
||||
if len(in.Edges) == 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := in.Relation.CreatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
in.Relation.CreatedAt = now
|
||||
now := time.Now()
|
||||
for _, e := range in.Edges {
|
||||
if e == nil || e.UserID <= 0 || e.FriendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
if e.CreatedAt.IsZero() {
|
||||
e.CreatedAt = now
|
||||
}
|
||||
if e.UpdatedAt.IsZero() {
|
||||
e.UpdatedAt = e.CreatedAt
|
||||
}
|
||||
}
|
||||
if !in.Edges[0].CreatedAt.IsZero() {
|
||||
now = in.Edges[0].CreatedAt
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
row := FriendRelation{UserSmall: in.Relation.UserSmall, UserBig: in.Relation.UserBig, CreatedAt: now}
|
||||
if err := tx.Where("user_small = ? AND user_big = ?", row.UserSmall, row.UserBig).
|
||||
Attrs(row).FirstOrCreate(&row).Error; err != nil {
|
||||
for _, e := range in.Edges {
|
||||
row := rowFromEdge(e)
|
||||
assigns := map[string]any{
|
||||
"status": entity.StatusFriend,
|
||||
"updated_at": e.UpdatedAt,
|
||||
}
|
||||
if e.Remark != "" {
|
||||
assigns["remark"] = e.Remark
|
||||
}
|
||||
if e.Source != 0 {
|
||||
assigns["source"] = e.Source
|
||||
}
|
||||
if err := tx.Where("user_id = ? AND friend_id = ?", row.UserID, row.FriendID).
|
||||
Attrs(row).Assign(assigns).FirstOrCreate(&row).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
if err := markRequestsAccepted(tx, in.Requests, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertRemarks(tx, in.Remarks)
|
||||
return markRequestsAccepted(tx, in.Requests, now)
|
||||
})
|
||||
}
|
||||
|
||||
@ -41,15 +60,13 @@ func (m *Repo) DeleteFriend(ctx context.Context, a, b int64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
small, big := entity.OrderUsers(a, b)
|
||||
res := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{})
|
||||
res := db.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", a, b, b, a).
|
||||
Delete(&FriendRelation{})
|
||||
if res.Error != nil {
|
||||
return mapDBErr(res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
return deleteRemarksBetween(tx, a, b)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,16 +2,27 @@ package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"spark-server/internal/module/friend/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
// FriendRelation maps table friend_relations.
|
||||
// FriendRelation maps table friend_relations (one directed edge per row).
|
||||
type FriendRelation struct {
|
||||
UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"`
|
||||
UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;comment:成为好友时间"`
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
|
||||
UserID int64 `gorm:"column:user_id;comment:关系所属用户"`
|
||||
FriendID int64 `gorm:"column:friend_id;comment:对方 user_id"`
|
||||
Status int `gorm:"column:status;comment:1=好友 0=非好友(仅用于单向拉黑占位)"`
|
||||
Remark string `gorm:"column:remark;comment:备注,仅自己可见"`
|
||||
Blocked bool `gorm:"column:is_blocked;comment:是否拉黑对方"`
|
||||
Pinned bool `gorm:"column:is_pinned;comment:会话消息置顶"`
|
||||
Source int `gorm:"column:source;comment:添加来源:0=未知 1=二维码 2=邮箱 3=spark_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;comment:成为好友或建边时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近更新时间"`
|
||||
}
|
||||
|
||||
func (FriendRelation) TableName() string { return "friend_relations" }
|
||||
@ -20,17 +31,28 @@ func (r *FriendRelation) entity() *entity.Relation {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &entity.Relation{UserSmall: r.UserSmall, UserBig: r.UserBig, CreatedAt: r.CreatedAt}
|
||||
return &entity.Relation{
|
||||
ID: r.ID, UserID: r.UserID, FriendID: r.FriendID, Status: entity.EdgeStatus(r.Status),
|
||||
Remark: r.Remark, Blocked: r.Blocked, Pinned: r.Pinned, Source: entity.Source(r.Source),
|
||||
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Repo) GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error) {
|
||||
func rowFromEdge(e *entity.Relation) FriendRelation {
|
||||
return FriendRelation{
|
||||
UserID: e.UserID, FriendID: e.FriendID, Status: int(e.Status),
|
||||
Remark: e.Remark, Blocked: e.Blocked, Pinned: e.Pinned, Source: int(e.Source),
|
||||
CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Repo) GetRelation(ctx context.Context, userID, friendID int64) (*entity.Relation, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
small, big := entity.OrderUsers(a, b)
|
||||
var row FriendRelation
|
||||
if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil {
|
||||
if err := db.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return row.entity(), nil
|
||||
@ -43,8 +65,8 @@ func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*ent
|
||||
}
|
||||
limit = entity.ClampLimit(limit)
|
||||
var rows []FriendRelation
|
||||
err = db.Where("user_small = ? OR user_big = ?", userID, userID).
|
||||
Order("created_at DESC").Limit(limit).Find(&rows).Error
|
||||
err = db.Where("user_id = ? AND status = ?", userID, entity.StatusFriend).
|
||||
Order("is_pinned DESC, created_at DESC").Limit(limit).Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
@ -54,3 +76,88 @@ func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*ent
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Repo) SetRemark(ctx context.Context, userID, friendID int64, remark string) error {
|
||||
return m.updateEdge(ctx, userID, friendID, map[string]any{"remark": remark, "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
func (m *Repo) SetPinned(ctx context.Context, userID, friendID int64, pinned bool) error {
|
||||
return m.updateEdge(ctx, userID, friendID, map[string]any{"is_pinned": pinned, "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
func (m *Repo) updateEdge(ctx context.Context, userID, friendID int64, values map[string]any) error {
|
||||
if userID <= 0 || friendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := db.Model(&FriendRelation{}).
|
||||
Where("user_id = ? AND friend_id = ? AND status = ?", userID, friendID, entity.StatusFriend).
|
||||
Updates(values)
|
||||
if res.Error != nil {
|
||||
return mapDBErr(res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Repo) IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) {
|
||||
rel, err := m.GetRelation(ctx, userID, targetID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return rel.Blocked, nil
|
||||
}
|
||||
|
||||
func (m *Repo) BlockedEither(ctx context.Context, a, b int64) (bool, error) {
|
||||
me, err := m.IsBlocked(ctx, a, b)
|
||||
if err != nil || me {
|
||||
return me, err
|
||||
}
|
||||
return m.IsBlocked(ctx, b, a)
|
||||
}
|
||||
|
||||
func (m *Repo) SetBlocked(ctx context.Context, userID, friendID int64, blocked bool) error {
|
||||
if userID <= 0 || friendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var row FriendRelation
|
||||
err := tx.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if !blocked {
|
||||
return nil
|
||||
}
|
||||
row = FriendRelation{
|
||||
UserID: userID, FriendID: friendID, Status: int(entity.StatusNone),
|
||||
Blocked: true, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&row).Updates(map[string]any{"is_blocked": blocked, "updated_at": now}).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
}
|
||||
if blocked {
|
||||
return rejectPairRequests(tx, userID, friendID, now)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -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{
|
||||
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)
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ type FriendRequest struct {
|
||||
ToUser int64 `gorm:"column:to_user;comment:被申请人 user_id"`
|
||||
Source int `gorm:"column:source;comment:来源:1=二维码 2=邮箱 3=spark_id"`
|
||||
Reason string `gorm:"column:reason;comment:申请理由"`
|
||||
Remark string `gorm:"column:remark;comment:申请人对对方的备注,同意后写入 friend_remarks"`
|
||||
Remark string `gorm:"column:remark;comment:申请人对对方的备注,同意后写入自己的好友边"`
|
||||
Status int `gorm:"column:status;comment:1=待处理 2=已同意 3=已拒绝"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;comment:首次申请时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近一次申请或处理时间"`
|
||||
@ -31,16 +31,16 @@ func (r *FriendRequest) entity() *entity.Request {
|
||||
return nil
|
||||
}
|
||||
return &entity.Request{
|
||||
ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source,
|
||||
Reason: r.Reason, Remark: r.Remark, Status: r.Status,
|
||||
ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: entity.Source(r.Source),
|
||||
Reason: r.Reason, Remark: r.Remark, Status: entity.RequestStatus(r.Status),
|
||||
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func requestFromEntity(r *entity.Request) FriendRequest {
|
||||
return FriendRequest{
|
||||
ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source,
|
||||
Reason: r.Reason, Remark: r.Remark, Status: r.Status,
|
||||
ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: int(r.Source),
|
||||
Reason: r.Reason, Remark: r.Remark, Status: int(r.Status),
|
||||
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@ -93,7 +93,7 @@ func (m *Repo) UpsertRequest(ctx context.Context, req *entity.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Repo) ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) {
|
||||
func (m *Repo) ListIncoming(ctx context.Context, toUserID int64, status entity.RequestStatus, limit int) ([]*entity.Request, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -10,9 +10,9 @@ import (
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
type pairKey struct {
|
||||
Small int64
|
||||
Big int64
|
||||
type edgeKey struct {
|
||||
UserID int64
|
||||
FriendID int64
|
||||
}
|
||||
|
||||
type requestKey struct {
|
||||
@ -20,41 +20,27 @@ type requestKey struct {
|
||||
To int64
|
||||
}
|
||||
|
||||
type remarkKey struct {
|
||||
UserID int64
|
||||
FriendID int64
|
||||
}
|
||||
|
||||
type blockKey struct {
|
||||
UserID int64
|
||||
BlockedID int64
|
||||
}
|
||||
|
||||
// MemoryRepo is an in-process friend store for tests and empty-DSN bring-up.
|
||||
type MemoryRepo struct {
|
||||
mu sync.Mutex
|
||||
relations map[pairKey]*entity.Relation
|
||||
relations map[edgeKey]*entity.Relation
|
||||
requests map[int64]*entity.Request
|
||||
byPair map[requestKey]int64
|
||||
remarks map[remarkKey]string
|
||||
blocks map[blockKey]time.Time
|
||||
reports []*entity.Report
|
||||
}
|
||||
|
||||
func NewMemoryRepo() *MemoryRepo {
|
||||
return &MemoryRepo{
|
||||
relations: make(map[pairKey]*entity.Relation),
|
||||
relations: make(map[edgeKey]*entity.Relation),
|
||||
requests: make(map[int64]*entity.Request),
|
||||
byPair: make(map[requestKey]int64),
|
||||
remarks: make(map[remarkKey]string),
|
||||
blocks: make(map[blockKey]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) GetRelation(_ context.Context, a, b int64) (*entity.Relation, error) {
|
||||
func (m *MemoryRepo) GetRelation(_ context.Context, userID, friendID int64) (*entity.Relation, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rel, ok := m.relations[pairOf(a, b)]
|
||||
rel, ok := m.relations[edgeKey{UserID: userID, FriendID: friendID}]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
@ -68,12 +54,17 @@ func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([]
|
||||
limit = entity.ClampLimit(limit)
|
||||
out := make([]*entity.Relation, 0)
|
||||
for _, rel := range m.relations {
|
||||
if rel.UserSmall == userID || rel.UserBig == userID {
|
||||
if rel.UserID == userID && rel.Status == entity.StatusFriend {
|
||||
cp := *rel
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Pinned != out[j].Pinned {
|
||||
return out[i].Pinned
|
||||
}
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
@ -125,7 +116,7 @@ func (m *MemoryRepo) UpsertRequest(_ context.Context, req *entity.Request) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) {
|
||||
func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status entity.RequestStatus, limit int) ([]*entity.Request, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
limit = entity.ClampLimit(limit)
|
||||
@ -147,7 +138,15 @@ func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, lim
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func pairOf(a, b int64) pairKey {
|
||||
small, big := entity.OrderUsers(a, b)
|
||||
return pairKey{Small: small, Big: big}
|
||||
func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) {
|
||||
for _, req := range m.requests {
|
||||
if req.Status != entity.RequestPending {
|
||||
continue
|
||||
}
|
||||
pair := (req.FromUser == a && req.ToUser == b) || (req.FromUser == b && req.ToUser == a)
|
||||
if pair {
|
||||
req.Status = entity.RequestRejected
|
||||
req.UpdatedAt = at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,76 +8,74 @@ import (
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func (m *MemoryRepo) GetRemark(_ context.Context, userID, friendID int64) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.remarks[remarkKey{UserID: userID, FriendID: friendID}], nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListRemarks(_ context.Context, userID int64, friendIDs []int64) (map[int64]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[int64]string, len(friendIDs))
|
||||
for _, id := range friendIDs {
|
||||
if s := m.remarks[remarkKey{UserID: userID, FriendID: id}]; s != "" {
|
||||
out[id] = s
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) SetRemark(_ context.Context, userID, friendID int64, remark string) error {
|
||||
return m.updateFriendEdge(userID, friendID, func(rel *entity.Relation) {
|
||||
rel.Remark = remark
|
||||
rel.UpdatedAt = time.Now()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) SetPinned(_ context.Context, userID, friendID int64, pinned bool) error {
|
||||
return m.updateFriendEdge(userID, friendID, func(rel *entity.Relation) {
|
||||
rel.Pinned = pinned
|
||||
rel.UpdatedAt = time.Now()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) updateFriendEdge(userID, friendID int64, fn func(*entity.Relation)) error {
|
||||
if userID <= 0 || friendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := remarkKey{UserID: userID, FriendID: friendID}
|
||||
if remark == "" {
|
||||
delete(m.remarks, key)
|
||||
return nil
|
||||
rel, ok := m.relations[edgeKey{UserID: userID, FriendID: friendID}]
|
||||
if !ok || rel.Status != entity.StatusFriend {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
m.remarks[key] = remark
|
||||
fn(rel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) IsBlocked(_ context.Context, userID, targetID int64) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.blocks[blockKey{UserID: userID, BlockedID: targetID}]
|
||||
return ok, nil
|
||||
rel, ok := m.relations[edgeKey{UserID: userID, FriendID: targetID}]
|
||||
return ok && rel.Blocked, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) BlockedEither(_ context.Context, a, b int64) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ab := m.blocks[blockKey{UserID: a, BlockedID: b}]
|
||||
_, ba := m.blocks[blockKey{UserID: b, BlockedID: a}]
|
||||
return ab || ba, nil
|
||||
ab := m.relations[edgeKey{UserID: a, FriendID: b}]
|
||||
ba := m.relations[edgeKey{UserID: b, FriendID: a}]
|
||||
return (ab != nil && ab.Blocked) || (ba != nil && ba.Blocked), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) CreateBlock(_ context.Context, b *entity.Block) error {
|
||||
if b == nil || b.UserID <= 0 || b.BlockedID <= 0 {
|
||||
func (m *MemoryRepo) SetBlocked(_ context.Context, userID, friendID int64, blocked bool) error {
|
||||
if userID <= 0 || friendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := b.CreatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
m.blocks[blockKey{UserID: b.UserID, BlockedID: b.BlockedID}] = now
|
||||
delete(m.relations, pairOf(b.UserID, b.BlockedID))
|
||||
delete(m.remarks, remarkKey{UserID: b.UserID, FriendID: b.BlockedID})
|
||||
delete(m.remarks, remarkKey{UserID: b.BlockedID, FriendID: b.UserID})
|
||||
m.rejectPendingLocked(b.UserID, b.BlockedID, now)
|
||||
now := time.Now()
|
||||
key := edgeKey{UserID: userID, FriendID: friendID}
|
||||
rel, ok := m.relations[key]
|
||||
if !ok {
|
||||
if !blocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) DeleteBlock(_ context.Context, userID, blockedID int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.blocks, blockKey{UserID: userID, BlockedID: blockedID})
|
||||
}
|
||||
m.relations[key] = &entity.Relation{
|
||||
UserID: userID, FriendID: friendID, Status: entity.StatusNone,
|
||||
Blocked: true, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
m.rejectPendingLocked(userID, friendID, now)
|
||||
return nil
|
||||
}
|
||||
rel.Blocked = blocked
|
||||
rel.UpdatedAt = now
|
||||
if blocked {
|
||||
m.rejectPendingLocked(userID, friendID, now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -93,19 +91,35 @@ func (m *MemoryRepo) CreateReport(_ context.Context, r *entity.Report) error {
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) error {
|
||||
if in.Relation == nil {
|
||||
if len(in.Edges) == 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := in.Relation.CreatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
in.Relation.CreatedAt = now
|
||||
now := time.Now()
|
||||
for _, e := range in.Edges {
|
||||
if e == nil || e.UserID <= 0 || e.FriendID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
key := pairKey{Small: in.Relation.UserSmall, Big: in.Relation.UserBig}
|
||||
if _, ok := m.relations[key]; !ok {
|
||||
cp := *in.Relation
|
||||
if e.CreatedAt.IsZero() {
|
||||
e.CreatedAt = now
|
||||
}
|
||||
if e.UpdatedAt.IsZero() {
|
||||
e.UpdatedAt = e.CreatedAt
|
||||
}
|
||||
key := edgeKey{UserID: e.UserID, FriendID: e.FriendID}
|
||||
if cur, ok := m.relations[key]; ok {
|
||||
cur.Status = entity.StatusFriend
|
||||
cur.UpdatedAt = e.UpdatedAt
|
||||
if e.Remark != "" {
|
||||
cur.Remark = e.Remark
|
||||
}
|
||||
if e.Source != 0 {
|
||||
cur.Source = e.Source
|
||||
}
|
||||
continue
|
||||
}
|
||||
cp := *e
|
||||
m.relations[key] = &cp
|
||||
}
|
||||
for _, id := range in.Requests {
|
||||
@ -114,38 +128,18 @@ func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) err
|
||||
req.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
for i := range in.Remarks {
|
||||
r := in.Remarks[i]
|
||||
if r.Text == "" {
|
||||
continue
|
||||
}
|
||||
m.remarks[remarkKey{UserID: r.UserID, FriendID: r.FriendID}] = r.Text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) DeleteFriend(_ context.Context, a, b int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := pairOf(a, b)
|
||||
if _, ok := m.relations[key]; !ok {
|
||||
_, ab := m.relations[edgeKey{UserID: a, FriendID: b}]
|
||||
_, ba := m.relations[edgeKey{UserID: b, FriendID: a}]
|
||||
if !ab && !ba {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
delete(m.relations, key)
|
||||
delete(m.remarks, remarkKey{UserID: a, FriendID: b})
|
||||
delete(m.remarks, remarkKey{UserID: b, FriendID: a})
|
||||
delete(m.relations, edgeKey{UserID: a, FriendID: b})
|
||||
delete(m.relations, edgeKey{UserID: b, FriendID: a})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) {
|
||||
for _, req := range m.requests {
|
||||
if req.Status != entity.RequestPending {
|
||||
continue
|
||||
}
|
||||
pair := (req.FromUser == a && req.ToUser == b) || (req.FromUser == b && req.ToUser == a)
|
||||
if pair {
|
||||
req.Status = entity.RequestRejected
|
||||
req.UpdatedAt = at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
const (
|
||||
StatusActive = 1
|
||||
StatusDissolved = 2
|
||||
StatusActive Status = 1
|
||||
StatusDissolved Status = 2
|
||||
|
||||
MaxNameLen = 32
|
||||
MaxAnnouncementLen = 1024
|
||||
MaxRemarkLen = 32
|
||||
MaxMembers = 200
|
||||
ListDefault = 20
|
||||
ListMax = 100
|
||||
@ -13,16 +15,24 @@ const (
|
||||
MaxCollageFaces = 9
|
||||
)
|
||||
|
||||
// Status is persisted as groups.status.
|
||||
type Status int
|
||||
|
||||
func (s Status) Valid() bool {
|
||||
return s == StatusActive || s == StatusDissolved
|
||||
}
|
||||
|
||||
// Group is a type-2 chat. ID is the AUTO_INCREMENT 群号; ConversationID is Snowflake.
|
||||
type Group struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
Name string
|
||||
Avatar string
|
||||
Announcement string
|
||||
OwnerID int64
|
||||
MutedAll bool
|
||||
MemberCount int
|
||||
Status int
|
||||
Status Status
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
@ -1,29 +1,51 @@
|
||||
package entity
|
||||
|
||||
const (
|
||||
RoleOwner = 1
|
||||
RoleAdmin = 2
|
||||
RoleMember = 3
|
||||
RoleOwner Role = 1
|
||||
RoleAdmin Role = 2
|
||||
RoleMember Role = 3
|
||||
)
|
||||
|
||||
// Role is persisted as group_members.role.
|
||||
type Role int
|
||||
|
||||
func (r Role) Valid() bool {
|
||||
return r == RoleOwner || r == RoleAdmin || r == RoleMember
|
||||
}
|
||||
|
||||
// Member is a row in group_members. SpeakMuted is send-ban, not 免打扰.
|
||||
type Member struct {
|
||||
GroupID int64
|
||||
UserID int64
|
||||
Role int
|
||||
Role Role
|
||||
Nickname string
|
||||
Remark string
|
||||
SpeakMuted bool
|
||||
JoinedAtMS int64
|
||||
}
|
||||
|
||||
func ValidRole(role int) bool {
|
||||
return role == RoleOwner || role == RoleAdmin || role == RoleMember
|
||||
return Role(role).Valid()
|
||||
}
|
||||
|
||||
func (m *Member) CanManage() bool {
|
||||
return m != nil && (m.Role == RoleOwner || m.Role == RoleAdmin)
|
||||
}
|
||||
|
||||
// CanModerate is kick / mute: owner may act on admin and member; admin may act on member only.
|
||||
func (m *Member) CanModerate(target *Member) bool {
|
||||
if m == nil || target == nil || !m.CanManage() {
|
||||
return false
|
||||
}
|
||||
if target.Role == RoleOwner {
|
||||
return false
|
||||
}
|
||||
if target.Role == RoleAdmin && m.Role != RoleOwner {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Member) CanSpeak(mutedAll bool) bool {
|
||||
if m == nil || m.SpeakMuted {
|
||||
return false
|
||||
|
||||
@ -47,7 +47,7 @@ func (s *Service) Create(ctx context.Context, ownerID int64, in *CreateInput) (*
|
||||
s.invalidateMembers(g.ConversationID)
|
||||
s.stampAvatar(ctx, g, ownerID, invitees)
|
||||
_ = s.notice(ctx, g, ownerID, entity.SystemKindCreated, invitees)
|
||||
return viewOf(g, entity.RoleOwner), nil
|
||||
return viewOf(g, &entity.Member{UserID: ownerID, Role: entity.RoleOwner}), nil
|
||||
}
|
||||
|
||||
func (s *Service) collectInvitees(ctx context.Context, ownerID int64, uids []int64) ([]int64, error) {
|
||||
|
||||
@ -18,7 +18,7 @@ func (s *Service) Get(ctx context.Context, userID, groupID int64) (*View, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return viewOf(g, mem.Role), nil
|
||||
return viewOf(g, mem), nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID int64, limit int) (*List, error) {
|
||||
@ -31,11 +31,8 @@ func (s *Service) List(ctx context.Context, userID int64, limit int) (*List, err
|
||||
}
|
||||
items := make([]*View, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
role := 0
|
||||
if mem, err := s.repo.GetMember(ctx, g.ID, userID); err == nil {
|
||||
role = mem.Role
|
||||
}
|
||||
items = append(items, viewOf(g, role))
|
||||
mem, _ := s.repo.GetMember(ctx, g.ID, userID)
|
||||
items = append(items, viewOf(g, mem))
|
||||
}
|
||||
return &List{Items: items}, nil
|
||||
}
|
||||
|
||||
@ -106,6 +106,10 @@ func TestCreateGroupAutoIncrementID(t *testing.T) {
|
||||
if err != nil || len(ids) != 3 {
|
||||
t.Fatalf("members %v %v", ids, err)
|
||||
}
|
||||
gid, err := svc.IDByConversation(ctx, g.ConversationID)
|
||||
if err != nil || gid != g.ID {
|
||||
t.Fatalf("group of conv %d %v", gid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupInviteKickLeaveMute(t *testing.T) {
|
||||
@ -267,3 +271,87 @@ func (n *noGetStore) Put(_ context.Context, key string, body []byte, _ string) e
|
||||
n.kv[key] = append([]byte(nil), body...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGroupAdminAnnouncementRemarkMentions(t *testing.T) {
|
||||
svc, friends, users := testGroups(t)
|
||||
ctx := t.Context()
|
||||
a := login(t, users, "test01@spark.com", 1)
|
||||
b := login(t, users, "test02@spark.com", 2)
|
||||
c := login(t, users, "test03@spark.com", 3)
|
||||
becomeFriends(t, friends, a, b)
|
||||
becomeFriends(t, friends, a, c)
|
||||
becomeFriends(t, friends, b, c)
|
||||
|
||||
g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID, c.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "新名字"
|
||||
announce := "今晚九点"
|
||||
if _, err := svc.Update(ctx, b.ID, g.ID, &UpdateInput{Name: &name, Announcement: &announce}); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("member update %v", err)
|
||||
}
|
||||
if _, err := svc.Update(ctx, a.ID, g.ID, &UpdateInput{Name: &name, Announcement: &announce}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := svc.Get(ctx, b.ID, g.ID)
|
||||
if err != nil || got.Name != name || got.Announcement != announce {
|
||||
t.Fatalf("profile %+v %v", got, err)
|
||||
}
|
||||
|
||||
admin := entity.RoleAdmin
|
||||
if _, err := svc.UpdateMember(ctx, a.ID, g.ID, b.ID, &UpdateMemberInput{Role: &admin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
muted := true
|
||||
if _, err := svc.UpdateMember(ctx, b.ID, g.ID, c.ID, &UpdateMemberInput{SpeakMuted: &muted}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UpdateMember(ctx, b.ID, g.ID, a.ID, &UpdateMemberInput{SpeakMuted: &muted}); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("admin mute owner %v", err)
|
||||
}
|
||||
if err := svc.RemoveMember(ctx, b.ID, g.ID, c.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Invite(ctx, a.ID, g.ID, &InviteInput{UIDs: []int64{c.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.CheckMentions(ctx, c.ID, g.ConversationID, true, nil); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("member at-all %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateMember(ctx, a.ID, g.ID, c.ID, &UpdateMemberInput{Role: &admin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.RemoveMember(ctx, b.ID, g.ID, c.ID); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("admin kick admin %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateMember(ctx, b.ID, g.ID, c.ID, &UpdateMemberInput{SpeakMuted: &muted}); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("admin mute admin %v", err)
|
||||
}
|
||||
|
||||
nick := "群里的我"
|
||||
remark := "周末球局"
|
||||
if _, err := svc.UpdateMember(ctx, c.ID, g.ID, c.ID, &UpdateMemberInput{Nickname: &nick, Remark: &remark}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
me, err := svc.Get(ctx, c.ID, g.ID)
|
||||
if err != nil || me.GroupNickname != nick || me.Remark != remark {
|
||||
t.Fatalf("self card %+v %v", me, err)
|
||||
}
|
||||
if _, err := svc.UpdateMember(ctx, b.ID, g.ID, c.ID, &UpdateMemberInput{Remark: &remark}); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("others remark %v", err)
|
||||
}
|
||||
|
||||
if err := svc.CheckMentions(ctx, c.ID, g.ConversationID, false, []int64{a.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.CheckMentions(ctx, a.ID, g.ConversationID, true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.CheckMentions(ctx, b.ID, g.ConversationID, true, []int64{c.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.CheckMentions(ctx, a.ID, g.ConversationID, false, []int64{999}); !errors.Is(err, errs.ErrInvalidArgument) {
|
||||
t.Fatalf("at outsider %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,9 +47,9 @@ func (s *Service) Invite(ctx context.Context, actorID, groupID int64, in *Invite
|
||||
_ = s.notice(ctx, g, actorID, entity.SystemKindInvite, ids)
|
||||
mem, err := s.requireMember(ctx, groupID, actorID)
|
||||
if err != nil {
|
||||
return viewOf(g, entity.RoleMember), nil
|
||||
return viewOf(g, &entity.Member{Role: entity.RoleMember}), nil
|
||||
}
|
||||
return viewOf(g, mem.Role), nil
|
||||
return viewOf(g, mem), nil
|
||||
}
|
||||
|
||||
func (s *Service) newMembers(ctx context.Context, g *entity.Group, actorID int64, uids []int64) ([]*entity.Member, error) {
|
||||
|
||||
@ -12,9 +12,10 @@ import (
|
||||
)
|
||||
|
||||
type UpdateMemberInput struct {
|
||||
Role *int `json:"role" validate:"omitempty,oneof=2 3" label:"field.role" errmsg:"oneof:validate.group.role_invalid"`
|
||||
Role *entity.Role `json:"role" validate:"omitempty,oneof=2 3" label:"field.role" errmsg:"oneof:validate.group.role_invalid"`
|
||||
SpeakMuted *bool `json:"speak_muted"`
|
||||
Nickname *string `json:"nickname" validate:"omitempty,max=32" label:"field.group_nickname" errmsg:"max:validate.group.nickname_invalid"`
|
||||
Remark *string `json:"remark" validate:"omitempty,max=32" label:"field.group_remark" errmsg:"max:validate.group.remark_invalid"`
|
||||
}
|
||||
|
||||
func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID int64, in *UpdateMemberInput) (*MemberView, error) {
|
||||
@ -27,7 +28,7 @@ func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID i
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Role == nil && in.SpeakMuted == nil && in.Nickname == nil {
|
||||
if in.Role == nil && in.SpeakMuted == nil && in.Nickname == nil && in.Remark == nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
g, err := s.loadActive(ctx, groupID)
|
||||
@ -51,6 +52,12 @@ func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID i
|
||||
func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member, in *UpdateMemberInput) error {
|
||||
lang := reqctx.Lang(ctx)
|
||||
self := actorID == target.UserID
|
||||
if in.Remark != nil {
|
||||
if !self {
|
||||
return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil))
|
||||
}
|
||||
target.Remark = strings.TrimSpace(*in.Remark)
|
||||
}
|
||||
if in.Nickname != nil {
|
||||
if !self {
|
||||
if _, err := s.requireManage(ctx, g.ID, actorID); err != nil {
|
||||
@ -69,10 +76,11 @@ func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID
|
||||
target.Role = *in.Role
|
||||
}
|
||||
if in.SpeakMuted != nil {
|
||||
if _, err := s.requireManage(ctx, g.ID, actorID); err != nil {
|
||||
actor, err := s.requireManage(ctx, g.ID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Role == entity.RoleOwner {
|
||||
if !actor.CanModerate(target) {
|
||||
return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil))
|
||||
}
|
||||
target.SpeakMuted = *in.SpeakMuted
|
||||
|
||||
@ -54,6 +54,17 @@ func (s *Service) MemberIDs(ctx context.Context, conversationID int64) ([]int64,
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *Service) IDByConversation(ctx context.Context, conversationID int64) (int64, error) {
|
||||
if !s.ready() || conversationID <= 0 {
|
||||
return 0, errs.ErrInvalidArgument
|
||||
}
|
||||
g, err := s.repo.GetByConversation(ctx, conversationID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return g.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) CanSend(ctx context.Context, userID, conversationID int64) error {
|
||||
if !s.ready() || userID <= 0 || conversationID <= 0 {
|
||||
return errs.ErrForbidden
|
||||
|
||||
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
|
||||
}
|
||||
msg := &msgentity.Message{
|
||||
ID: msgID, ConversationID: g.ConversationID, SenderID: 0,
|
||||
ID: msgID, ConversationID: g.ConversationID, SenderID: 0, GroupID: g.ID,
|
||||
ClientMsgID: fmt.Sprintf("sys:group:%d", msgID),
|
||||
Type: msgentity.TypeSystem, Content: raw,
|
||||
Type: msgentity.TypeSystem, Kind: msgentity.KindGroup, Content: raw,
|
||||
Status: msgentity.StatusNormal, CreatedAtMS: now.UnixMilli(),
|
||||
}
|
||||
if err := s.msgs.Create(ctx, msg); err != nil {
|
||||
@ -50,7 +50,7 @@ func (s *Service) notice(ctx context.Context, g *entity.Group, actorID int64, ki
|
||||
FromUserID: actorID, ToUserIDs: to,
|
||||
Payload: reply.MustMarshal(reply.OK(groupPushBody{
|
||||
MsgID: msgID, ConversationID: g.ConversationID, SenderID: 0,
|
||||
MessageType: int16(msgentity.TypeSystem), Content: raw, CreatedAt: now.UnixMilli(),
|
||||
MessageType: msgentity.TypeSystem, Content: raw, CreatedAt: now.UnixMilli(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@ -59,7 +59,7 @@ type groupPushBody struct {
|
||||
MsgID int64 `json:"msg_id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
MessageType int16 `json:"message_type"`
|
||||
MessageType msgentity.Type `json:"message_type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ func (s *Service) kick(ctx context.Context, g *entity.Group, actorID int64, targ
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Role == entity.RoleOwner || (target.Role == entity.RoleAdmin && actor.Role != entity.RoleOwner) {
|
||||
if !actor.CanModerate(target) {
|
||||
return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil))
|
||||
}
|
||||
if err := s.repo.RemoveMember(ctx, g.ID, target.UserID); err != nil {
|
||||
|
||||
@ -42,7 +42,8 @@ func (s *Service) Transfer(ctx context.Context, ownerID, groupID int64, in *Tran
|
||||
g.OwnerID = in.UID
|
||||
s.invalidateMembers(g.ConversationID)
|
||||
_ = s.notice(ctx, g, ownerID, entity.SystemKindTransfer, []int64{in.UID})
|
||||
return viewOf(g, entity.RoleMember), nil
|
||||
mem, _ := s.repo.GetMember(ctx, groupID, ownerID)
|
||||
return viewOf(g, mem), nil
|
||||
}
|
||||
|
||||
func (s *Service) Dissolve(ctx context.Context, ownerID, groupID int64) error {
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
type UpdateInput struct {
|
||||
Name *string `json:"name" validate:"omitempty,min=1,max=32" label:"field.group_name" errmsg:"min:validate.group.name_invalid;max:validate.group.name_invalid"`
|
||||
Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar"`
|
||||
Announcement *string `json:"announcement" validate:"omitempty,max=1024" label:"field.group_announcement" errmsg:"max:validate.group.announcement_invalid"`
|
||||
MutedAll *bool `json:"muted_all"`
|
||||
}
|
||||
|
||||
@ -25,7 +26,7 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name == nil && in.Avatar == nil && in.MutedAll == nil {
|
||||
if in.Name == nil && in.Avatar == nil && in.Announcement == nil && in.MutedAll == nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
g, err := s.loadActive(ctx, groupID)
|
||||
@ -45,6 +46,9 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
|
||||
}
|
||||
g.Avatar = av
|
||||
}
|
||||
if in.Announcement != nil {
|
||||
g.Announcement = strings.TrimSpace(*in.Announcement)
|
||||
}
|
||||
if in.MutedAll != nil {
|
||||
g.MutedAll = *in.MutedAll
|
||||
}
|
||||
@ -56,5 +60,5 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return viewOf(g, mem.Role), nil
|
||||
return viewOf(g, mem), nil
|
||||
}
|
||||
|
||||
@ -13,10 +13,13 @@ type View struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Announcement string `json:"announcement"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
MutedAll bool `json:"muted_all"`
|
||||
MemberCount int `json:"member_count"`
|
||||
Role int `json:"role"`
|
||||
Role entity.Role `json:"role"`
|
||||
Remark string `json:"remark"`
|
||||
GroupNickname string `json:"group_nickname"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
@ -25,8 +28,8 @@ type MemberView struct {
|
||||
SparkID string `json:"spark_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Gender int `json:"gender"`
|
||||
Role int `json:"role"`
|
||||
Gender userentity.Gender `json:"gender"`
|
||||
Role entity.Role `json:"role"`
|
||||
GroupNickname string `json:"group_nickname"`
|
||||
SpeakMuted bool `json:"speak_muted"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
@ -40,15 +43,21 @@ type MemberList struct {
|
||||
Items []*MemberView `json:"items"`
|
||||
}
|
||||
|
||||
func viewOf(g *entity.Group, role int) *View {
|
||||
func viewOf(g *entity.Group, mem *entity.Member) *View {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return &View{
|
||||
v := &View{
|
||||
ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar,
|
||||
OwnerID: g.OwnerID, MutedAll: g.MutedAll, MemberCount: g.MemberCount,
|
||||
Role: role, CreatedAt: g.CreatedAtMS,
|
||||
Announcement: g.Announcement, OwnerID: g.OwnerID, MutedAll: g.MutedAll,
|
||||
MemberCount: g.MemberCount, CreatedAt: g.CreatedAtMS,
|
||||
}
|
||||
if mem != nil {
|
||||
v.Role = mem.Role
|
||||
v.Remark = mem.Remark
|
||||
v.GroupNickname = mem.Nickname
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func memberViewOf(mem *entity.Member, u *userentity.User) *MemberView {
|
||||
|
||||
@ -14,6 +14,7 @@ type Group struct {
|
||||
ConversationID int64 `gorm:"column:conversation_id;uniqueIndex;comment:对应 conversations.id(Snowflake),发消息与同步用"`
|
||||
Name string `gorm:"column:name;comment:群名称"`
|
||||
Avatar string `gorm:"column:avatar;comment:群头像对象存储 key,空表示未设置"`
|
||||
Announcement string `gorm:"column:announcement;comment:群公告,仅群主/管理员可改"`
|
||||
OwnerID int64 `gorm:"column:owner_id;comment:群主 user_id"`
|
||||
MutedAll int `gorm:"column:muted_all;comment:0=未全员禁言 1=全员禁言(群主/管理员仍可发言)"`
|
||||
MemberCount int `gorm:"column:member_count;comment:当前成员数,含群主"`
|
||||
@ -29,8 +30,8 @@ func (r *Group) entity() *entity.Group {
|
||||
return nil
|
||||
}
|
||||
g := &entity.Group{
|
||||
ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, OwnerID: r.OwnerID,
|
||||
MemberCount: r.MemberCount, Status: r.Status,
|
||||
ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, Announcement: r.Announcement, OwnerID: r.OwnerID,
|
||||
MemberCount: r.MemberCount, Status: entity.Status(r.Status),
|
||||
CreatedAtMS: r.CreatedAt.UnixMilli(), UpdatedAtMS: r.UpdatedAt.UnixMilli(),
|
||||
}
|
||||
g.MutedAll = r.MutedAll != 0
|
||||
@ -44,15 +45,15 @@ func groupRow(g *entity.Group) *Group {
|
||||
updated = now
|
||||
}
|
||||
row := &Group{
|
||||
ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, OwnerID: g.OwnerID,
|
||||
MemberCount: g.MemberCount, Status: g.Status,
|
||||
ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, Announcement: g.Announcement, OwnerID: g.OwnerID,
|
||||
MemberCount: g.MemberCount, Status: int(g.Status),
|
||||
CreatedAt: now, UpdatedAt: updated,
|
||||
}
|
||||
if g.MutedAll {
|
||||
row.MutedAll = 1
|
||||
}
|
||||
if row.Status == 0 {
|
||||
row.Status = entity.StatusActive
|
||||
row.Status = int(entity.StatusActive)
|
||||
}
|
||||
return row
|
||||
}
|
||||
@ -122,6 +123,7 @@ func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
|
||||
res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{
|
||||
"name": g.Name,
|
||||
"avatar": g.Avatar,
|
||||
"announcement": g.Announcement,
|
||||
"muted_all": boolInt(g.MutedAll),
|
||||
"updated_at": msTime(g.UpdatedAtMS),
|
||||
})
|
||||
|
||||
@ -14,6 +14,7 @@ type GroupMember struct {
|
||||
UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"`
|
||||
Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"`
|
||||
Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"`
|
||||
Remark string `gorm:"column:remark;comment:成员对该群的备注,仅自己可见"`
|
||||
SpeakMuted int `gorm:"column:speak_muted;comment:0=未禁言 1=被单独禁言"`
|
||||
JoinedAt time.Time `gorm:"column:joined_at;comment:入群时间"`
|
||||
}
|
||||
@ -25,8 +26,8 @@ func (r *GroupMember) entity() *entity.Member {
|
||||
return nil
|
||||
}
|
||||
m := &entity.Member{
|
||||
GroupID: r.GroupID, UserID: r.UserID, Role: r.Role,
|
||||
Nickname: r.Nickname, JoinedAtMS: r.JoinedAt.UnixMilli(),
|
||||
GroupID: r.GroupID, UserID: r.UserID, Role: entity.Role(r.Role),
|
||||
Nickname: r.Nickname, Remark: r.Remark, JoinedAtMS: r.JoinedAt.UnixMilli(),
|
||||
}
|
||||
m.SpeakMuted = r.SpeakMuted != 0
|
||||
return m
|
||||
@ -34,14 +35,14 @@ func (r *GroupMember) entity() *entity.Member {
|
||||
|
||||
func memberRow(m *entity.Member) *GroupMember {
|
||||
row := &GroupMember{
|
||||
GroupID: m.GroupID, UserID: m.UserID, Role: m.Role,
|
||||
Nickname: m.Nickname, JoinedAt: msTime(m.JoinedAtMS),
|
||||
GroupID: m.GroupID, UserID: m.UserID, Role: int(m.Role),
|
||||
Nickname: m.Nickname, Remark: m.Remark, JoinedAt: msTime(m.JoinedAtMS),
|
||||
}
|
||||
if m.SpeakMuted {
|
||||
row.SpeakMuted = 1
|
||||
}
|
||||
if row.Role == 0 {
|
||||
row.Role = entity.RoleMember
|
||||
row.Role = int(entity.RoleMember)
|
||||
}
|
||||
return row
|
||||
}
|
||||
@ -109,6 +110,7 @@ func (m *Repo) UpdateMember(ctx context.Context, mem *entity.Member) error {
|
||||
res := db.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", mem.GroupID, mem.UserID).Updates(map[string]any{
|
||||
"role": mem.Role,
|
||||
"nickname": mem.Nickname,
|
||||
"remark": mem.Remark,
|
||||
"speak_muted": boolInt(mem.SpeakMuted),
|
||||
})
|
||||
if res.Error != nil {
|
||||
|
||||
@ -140,6 +140,7 @@ func (m *MemoryRepo) UpdateProfile(_ context.Context, g *entity.Group) error {
|
||||
}
|
||||
cur.Name = g.Name
|
||||
cur.Avatar = g.Avatar
|
||||
cur.Announcement = g.Announcement
|
||||
cur.MutedAll = g.MutedAll
|
||||
cur.UpdatedAtMS = g.UpdatedAtMS
|
||||
if cur.UpdatedAtMS <= 0 {
|
||||
@ -164,6 +165,7 @@ func (m *MemoryRepo) UpdateMember(_ context.Context, mem *entity.Member) error {
|
||||
}
|
||||
cur.Role = mem.Role
|
||||
cur.Nickname = mem.Nickname
|
||||
cur.Remark = mem.Remark
|
||||
cur.SpeakMuted = mem.SpeakMuted
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ func (m *Repo) CreateGroup(ctx context.Context, g *entity.Group, members []*enti
|
||||
now := msTime(g.CreatedAtMS)
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
conv := convmodel.Conversation{
|
||||
ID: g.ConversationID, Type: conventity.TypeGroup, LastMsgAt: now, CreatedAt: now,
|
||||
ID: g.ConversationID, Type: int(conventity.TypeGroup), LastMsgAt: now, CreatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&conv).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// Package entity holds inbox preview types. List/unread live in Redis, not MySQL.
|
||||
package entity
|
||||
|
||||
type Preview struct {
|
||||
@ -6,3 +5,17 @@ type Preview struct {
|
||||
MsgID int64
|
||||
Snippet []byte
|
||||
}
|
||||
|
||||
// DialogHit is a ZSET member updated after dialog_score.
|
||||
type DialogHit struct {
|
||||
ConversationID int64
|
||||
Score int64
|
||||
}
|
||||
|
||||
// Dialog is one conversation row for SYNC_INBOX.
|
||||
type Dialog struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
Score int64 `json:"score"`
|
||||
Unread int64 `json:"unread"`
|
||||
Preview []byte `json:"preview"`
|
||||
}
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
// Package logic is the Redis inbox (conversation list) contract. No MySQL on this hot path.
|
||||
package logic
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"spark-server/internal/module/inbox/entity"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
TouchUnread(ctx context.Context, userID, conversationID int64, score int64) error
|
||||
TouchRead(ctx context.Context, userID, conversationID int64, score int64) error
|
||||
IncrUnread(ctx context.Context, userID, conversationID int64, delta int64) error
|
||||
SetPreview(ctx context.Context, conversationID int64, preview []byte) error
|
||||
ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]int64, error)
|
||||
ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, error)
|
||||
LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error)
|
||||
}
|
||||
|
||||
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.
|
||||
type Content struct {
|
||||
Type Type `json:"t"`
|
||||
ReplyTo int64 `json:"reply_to,omitempty"`
|
||||
AtAll bool `json:"at_all,omitempty"`
|
||||
AtUIDs []int64 `json:"at_uids,omitempty"`
|
||||
Text *Text `json:"text,omitempty"`
|
||||
Image *Image `json:"image,omitempty"`
|
||||
Voice *Voice `json:"voice,omitempty"`
|
||||
@ -101,6 +104,12 @@ func (c *Content) Validate() error {
|
||||
if !c.Type.Valid() {
|
||||
return fmt.Errorf("content: unknown type %d", c.Type)
|
||||
}
|
||||
if c.ReplyTo < 0 {
|
||||
return fmt.Errorf("content: reply_to")
|
||||
}
|
||||
if err := validateMentions(c); err != nil {
|
||||
return err
|
||||
}
|
||||
got, n := c.populated()
|
||||
if n != 1 {
|
||||
return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n)
|
||||
@ -185,6 +194,32 @@ func validateText(t *Text) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMentions(c *Content) error {
|
||||
if c.Type == TypeSystem && (c.AtAll || len(c.AtUIDs) > 0) {
|
||||
return fmt.Errorf("content: mentions")
|
||||
}
|
||||
if len(c.AtUIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(c.AtUIDs))
|
||||
out := make([]int64, 0, len(c.AtUIDs))
|
||||
for _, id := range c.AtUIDs {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("content: at_uids")
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
if len(out) > MaxAtUIDs {
|
||||
return fmt.Errorf("content: at_uids")
|
||||
}
|
||||
c.AtUIDs = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMediaKey(kind, key string) error {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("content: %s key empty", kind)
|
||||
|
||||
@ -26,6 +26,22 @@ func TestTextMarshalIsJSONBytesNotRawString(t *testing.T) {
|
||||
if got.Type != TypeText || got.Text == nil || got.Text.Body != "hello" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got.ReplyTo != 0 {
|
||||
t.Fatalf("reply_to %d", got.ReplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyToRoundTrip(t *testing.T) {
|
||||
c := NewText("re")
|
||||
c.ReplyTo = 42
|
||||
raw, err := c.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := UnmarshalContent(raw)
|
||||
if err != nil || got.ReplyTo != 42 || got.Text.Body != "re" {
|
||||
t.Fatalf("%v %+v", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaTypesRequireObjectKey(t *testing.T) {
|
||||
@ -86,6 +102,25 @@ func TestMessagePayloadChecksColumnType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionsRoundTrip(t *testing.T) {
|
||||
c := NewText("hello @all")
|
||||
c.AtAll = true
|
||||
c.AtUIDs = []int64{2, 2, 3}
|
||||
raw, err := c.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := UnmarshalContent(raw)
|
||||
if err != nil || !got.AtAll || len(got.AtUIDs) != 2 || got.AtUIDs[0] != 2 || got.AtUIDs[1] != 3 {
|
||||
t.Fatalf("%v %+v", err, got)
|
||||
}
|
||||
sys := NewSystem(&System{Kind: "x", Body: "y"})
|
||||
sys.AtAll = true
|
||||
if _, err := sys.Marshal(); err == nil {
|
||||
t.Fatal("system mentions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectKeysAndReplace(t *testing.T) {
|
||||
img := NewImage(&Image{Key: "chat/a.jpg", ThumbKey: "chat/t.jpg"})
|
||||
keys := img.ObjectKeys()
|
||||
|
||||
@ -2,15 +2,30 @@ package entity
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Kind selects private_message vs group_message. Same numbers as conversation.Type; not a SQL column.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindPrivate Kind = 1
|
||||
KindGroup Kind = 2
|
||||
)
|
||||
|
||||
func (k Kind) Valid() bool {
|
||||
return k == KindPrivate || k == KindGroup
|
||||
}
|
||||
|
||||
// Message is a persisted IM body. Content is serialized JSON bytes, not a string.
|
||||
type Message struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
SenderID int64
|
||||
ReceiverID int64 // private_message only; 0 for system
|
||||
GroupID int64 // group_message only; groups.id 群号
|
||||
ClientMsgID string
|
||||
Type Type
|
||||
Kind Kind
|
||||
Content []byte
|
||||
Status int
|
||||
Status Status
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
|
||||
@ -21,10 +21,20 @@ func (t Type) Valid() bool {
|
||||
}
|
||||
|
||||
const (
|
||||
StatusNormal = 1
|
||||
StatusRecalled = 2
|
||||
StatusHidden = 3
|
||||
StatusNormal Status = 1
|
||||
StatusRecalled Status = 2
|
||||
StatusHidden Status = 3
|
||||
)
|
||||
|
||||
// Status is persisted as private_message/group_message.status.
|
||||
type Status int
|
||||
|
||||
func (s Status) Valid() bool {
|
||||
return s == StatusNormal || s == StatusRecalled || s == StatusHidden
|
||||
}
|
||||
|
||||
// MaxContentBytes is the serialized content size cap (matches TCP Frame max).
|
||||
const MaxContentBytes = 65536
|
||||
|
||||
// MaxAtUIDs is how many members a group message may @ (excluding @all).
|
||||
const MaxAtUIDs = 50
|
||||
|
||||
@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
deliverentity "spark-server/internal/module/deliver/entity"
|
||||
"spark-server/internal/module/message/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
"spark-server/internal/pkg/validate"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, message *entity.Message) error
|
||||
GetByID(ctx context.Context, id int64) (*entity.Message, error)
|
||||
GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error)
|
||||
ListByConversation(ctx context.Context, conversationID, lastMsgID int64, limit int) ([]*entity.Message, error)
|
||||
}
|
||||
const cmdMessage uint16 = 6
|
||||
|
||||
type SendRequest struct {
|
||||
ConversationID int64
|
||||
SenderID int64
|
||||
DeviceID uint64
|
||||
ClientMsgID string
|
||||
MsgID int64
|
||||
Content *entity.Content
|
||||
type SendInput struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
ClientMsgID string `json:"client_msg_id" validate:"required,max=64" label:"field.client_msg_id" errmsg:"required:validate.message.client_msg_required"`
|
||||
MsgID int64 `json:"msg_id"`
|
||||
Content json.RawMessage `json:"content" validate:"required" label:"field.content" errmsg:"required:validate.message.content_required"`
|
||||
}
|
||||
|
||||
type SendResponse struct {
|
||||
MsgID int64
|
||||
ClientMsgID string
|
||||
ServerTimeMS int64
|
||||
MsgID int64 `json:"msg_id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
ClientMsgID string `json:"client_msg_id"`
|
||||
ServerTimeMS int64 `json:"server_time"`
|
||||
}
|
||||
|
||||
// Service owns persist + enqueue. SEND_ACK happens only after both succeed.
|
||||
type Service interface {
|
||||
GenerateMsgID(ctx context.Context) (int64, error)
|
||||
SendMessage(ctx context.Context, req *SendRequest) (*SendResponse, error)
|
||||
func (s *Service) SendMessage(ctx context.Context, userID int64, deviceID uint64, in *SendInput) (*SendResponse, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if in == nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := entity.UnmarshalContent(in.Content)
|
||||
if err != nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
conv, memberIDs, err := s.prepareSend(ctx, userID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.checkReplyTo(ctx, conv.ID, content.ReplyTo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.checkMentions(ctx, userID, conv, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := content.Marshal()
|
||||
if err != nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
now := time.Now()
|
||||
msg := &entity.Message{
|
||||
ConversationID: conv.ID, SenderID: userID, ClientMsgID: in.ClientMsgID,
|
||||
Type: content.Type, Kind: kindOfConv(conv), Content: raw, Status: entity.StatusNormal, CreatedAtMS: now.UnixMilli(),
|
||||
}
|
||||
if err := s.stampTargets(ctx, userID, conv, memberIDs, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assignID(in.MsgID, now, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ack, dup, err := s.persistSend(ctx, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueMessage(ctx, ack, memberIDs, userID, deviceID, dup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SendResponse{MsgID: ack.ID, ConversationID: ack.ConversationID, ClientMsgID: ack.ClientMsgID, ServerTimeMS: ack.CreatedAtMS}, nil
|
||||
}
|
||||
|
||||
func (s *Service) enqueueMessage(ctx context.Context, msg *entity.Message, memberIDs []int64, userID int64, deviceID uint64, retry bool) error {
|
||||
if s.queue == nil {
|
||||
return nil
|
||||
}
|
||||
job := &deliverentity.Job{
|
||||
MsgID: msg.ID, Cmd: cmdMessage, ConversationID: msg.ConversationID,
|
||||
FromUserID: userID, FromDeviceID: deviceID, ExcludeDevice: deviceID,
|
||||
ToUserIDs: memberIDs, Payload: pushBytes(msg), Preview: previewOf(msg),
|
||||
}
|
||||
if retry {
|
||||
job.NotMailbox = true
|
||||
}
|
||||
return s.queue.Push(ctx, job)
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type clientKey struct {
|
||||
SenderID int64
|
||||
ConversationID int64
|
||||
ClientMsgID string
|
||||
}
|
||||
|
||||
type hideKey struct {
|
||||
UserID int64
|
||||
MsgID int64
|
||||
}
|
||||
|
||||
// MemoryRepo is an in-process message store for tests and empty-DSN bring-up.
|
||||
type MemoryRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int64]*entity.Message
|
||||
byClient map[clientKey]int64
|
||||
hidden map[hideKey]struct{}
|
||||
}
|
||||
|
||||
func NewMemoryRepo() *MemoryRepo {
|
||||
return &MemoryRepo{byID: make(map[int64]*entity.Message)}
|
||||
return &MemoryRepo{
|
||||
byID: make(map[int64]*entity.Message),
|
||||
byClient: make(map[clientKey]int64),
|
||||
hidden: make(map[hideKey]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) clone(row *entity.Message) *entity.Message {
|
||||
cp := *row
|
||||
if cp.Content != nil {
|
||||
cp.Content = append([]byte(nil), row.Content...)
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) Create(_ context.Context, message *entity.Message) error {
|
||||
return m.put(message, false)
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) CreateAndBump(_ context.Context, message *entity.Message) error {
|
||||
return m.put(message, true)
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) put(message *entity.Message, _ bool) error {
|
||||
if message == nil || message.ID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := clientKey{SenderID: message.SenderID, ConversationID: message.ConversationID, ClientMsgID: message.ClientMsgID}
|
||||
if _, ok := m.byID[message.ID]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
cp := *message
|
||||
if cp.Content != nil {
|
||||
cp.Content = append([]byte(nil), message.Content...)
|
||||
if message.ClientMsgID != "" {
|
||||
if _, ok := m.byClient[key]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
m.byID[message.ID] = &cp
|
||||
m.byClient[key] = message.ID
|
||||
}
|
||||
m.byID[message.ID] = m.clone(message)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -42,11 +78,61 @@ func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.Message, erro
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
cp := *row
|
||||
if cp.Content != nil {
|
||||
cp.Content = append([]byte(nil), row.Content...)
|
||||
return m.clone(row), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) GetByClientMsgID(_ context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
id, ok := m.byClient[clientKey{SenderID: senderID, ConversationID: conversationID, ClientMsgID: clientMsgID}]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
return &cp, nil
|
||||
return m.clone(m.byID[id]), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListByConversation(_ context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
out := make([]*entity.Message, 0)
|
||||
for _, row := range m.byID {
|
||||
if row.ConversationID != conversationID || row.ID <= lastMsgID || row.Status == entity.StatusHidden {
|
||||
continue
|
||||
}
|
||||
if _, ok := m.hidden[hideKey{UserID: viewerID, MsgID: row.ID}]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.clone(row))
|
||||
}
|
||||
sortMessages(out)
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) Recall(_ context.Context, id int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
row, ok := m.byID[id]
|
||||
if !ok || row.Status != entity.StatusNormal {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
row.Status = entity.StatusRecalled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) HideForUser(_ context.Context, userID int64, msg *entity.Message) error {
|
||||
if userID <= 0 || msg == nil || msg.ID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.hidden[hideKey{UserID: userID, MsgID: msg.ID}] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) All() []*entity.Message {
|
||||
@ -54,11 +140,17 @@ func (m *MemoryRepo) All() []*entity.Message {
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*entity.Message, 0, len(m.byID))
|
||||
for _, row := range m.byID {
|
||||
cp := *row
|
||||
if cp.Content != nil {
|
||||
cp.Content = append([]byte(nil), row.Content...)
|
||||
}
|
||||
out = append(out, &cp)
|
||||
out = append(out, m.clone(row))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortMessages(rows []*entity.Message) {
|
||||
for i := 1; i < len(rows); i++ {
|
||||
j := i
|
||||
for j > 0 && rows[j-1].ID > rows[j].ID {
|
||||
rows[j-1], rows[j] = rows[j], rows[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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