fix: align group management APIs
This commit is contained in:
+7
-4
@@ -931,11 +931,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
**状态:新增。**
|
**状态:新增。**
|
||||||
|
|
||||||
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
|
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
|
||||||
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
|
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
|
||||||
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
|
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
|
||||||
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
|
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
|
||||||
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式接口通过 Header `X-Action-Token` 携带。
|
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式、群主转让、解散群聊接口通过 Header `X-Action-Token` 携带。
|
||||||
- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
|
- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
|
||||||
|
|
||||||
### 1.8 实名认证.png
|
### 1.8 实名认证.png
|
||||||
@@ -1418,7 +1418,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
|
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
|
||||||
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
|
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
|
||||||
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{}`;可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` |
|
| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` |
|
||||||
| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` |
|
| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` |
|
||||||
@@ -1901,7 +1901,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` |
|
| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` |
|
||||||
| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` |
|
| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` |
|
||||||
| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` |
|
| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` |
|
||||||
| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` |
|
| `DELETE /v1/im/groups/{topic}` | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true,"status":"dissolved","version":2}` |
|
||||||
| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` |
|
| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` |
|
||||||
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
@@ -2721,8 +2721,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
**状态:新增 App 配置接口。**
|
**状态:新增 App 配置接口。**
|
||||||
|
|
||||||
|
完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
|
||||||
|
|
||||||
- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
|
- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
|
||||||
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
|
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
|
||||||
|
- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。
|
||||||
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
|
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
|
||||||
- 返回 `current_version,has_update,force_update,latest_version,min_supported_version,update_type=none|recommended|force,release_notes,store_url,package_url,checksum,published_at`。
|
- 返回 `current_version,has_update,force_update,latest_version,min_supported_version,update_type=none|recommended|force,release_notes,store_url,package_url,checksum,published_at`。
|
||||||
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
|
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
id int
|
||||||
|
account account
|
||||||
|
wsURL string
|
||||||
|
conn *websocket.Conn
|
||||||
|
sendMu sync.Mutex
|
||||||
|
stats *probeStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeStats struct {
|
||||||
|
expected atomic.Int64
|
||||||
|
received atomic.Int64
|
||||||
|
acks atomic.Int64
|
||||||
|
errors atomic.Int64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
latencies []time.Duration
|
||||||
|
seen map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var wsList string
|
||||||
|
var apiKey string
|
||||||
|
var accountsPath string
|
||||||
|
var topic string
|
||||||
|
var subscribers int
|
||||||
|
var publishers int
|
||||||
|
var rate int
|
||||||
|
var duration time.Duration
|
||||||
|
var ramp time.Duration
|
||||||
|
var insecure bool
|
||||||
|
|
||||||
|
flag.StringVar(&wsList, "ws", "ws://localhost:6060/v0/channels", "Comma-separated websocket endpoints, one or more pods/LBs.")
|
||||||
|
flag.StringVar(&apiKey, "apikey", "", "Tinode API key appended to websocket URL.")
|
||||||
|
flag.StringVar(&accountsPath, "accounts", "loadtest/users.csv", "CSV: username,password[,token].")
|
||||||
|
flag.StringVar(&topic, "topic", "", "Group topic to subscribe and publish to, e.g. grpABC.")
|
||||||
|
flag.IntVar(&subscribers, "subscribers", 100, "Number of websocket subscribers.")
|
||||||
|
flag.IntVar(&publishers, "publishers", 1, "Number of publishers selected from subscribers.")
|
||||||
|
flag.IntVar(&rate, "rate", 10, "Total publish rate, messages per second.")
|
||||||
|
flag.DurationVar(&duration, "duration", time.Minute, "Publish duration.")
|
||||||
|
flag.DurationVar(&ramp, "ramp", 10*time.Second, "Subscriber connection ramp duration.")
|
||||||
|
flag.BoolVar(&insecure, "insecure", false, "Skip TLS verification for wss endpoints.")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if topic == "" || subscribers <= 0 || publishers <= 0 || rate <= 0 || duration <= 0 {
|
||||||
|
log.Fatal("topic, subscribers, publishers, rate, and duration are required and must be positive")
|
||||||
|
}
|
||||||
|
endpoints := parseEndpoints(wsList, apiKey)
|
||||||
|
accounts, err := readAccounts(accountsPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(accounts) < subscribers {
|
||||||
|
log.Fatalf("need at least %d accounts, got %d", subscribers, len(accounts))
|
||||||
|
}
|
||||||
|
if publishers > subscribers {
|
||||||
|
publishers = subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &probeStats{seen: make(map[string]struct{})}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
clients := make([]*client, subscribers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < subscribers; i++ {
|
||||||
|
c := &client{id: i, account: accounts[i], wsURL: endpoints[i%len(endpoints)], stats: stats}
|
||||||
|
clients[i] = c
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := c.connectAndSubscribe(ctx, topic, insecure); err != nil {
|
||||||
|
stats.errors.Add(1)
|
||||||
|
log.Printf("client %d connect failed: %v", c.id, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if ramp > 0 {
|
||||||
|
time.Sleep(ramp / time.Duration(subscribers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
active := 0
|
||||||
|
for _, c := range clients {
|
||||||
|
if c.conn != nil {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active == 0 {
|
||||||
|
log.Fatal("no active websocket clients")
|
||||||
|
}
|
||||||
|
log.Printf("subscribed clients=%d publishers=%d endpoints=%d topic=%s", active, publishers, len(endpoints), topic)
|
||||||
|
|
||||||
|
publishDone := make(chan struct{})
|
||||||
|
go publishLoop(ctx, clients[:publishers], topic, rate, duration, stats, publishDone)
|
||||||
|
<-publishDone
|
||||||
|
log.Printf("publish finished; waiting 10s for fanout drain")
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
cancel()
|
||||||
|
for _, c := range clients {
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printSummary(stats, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) connectAndSubscribe(ctx context.Context, topic string, insecure bool) error {
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
if insecure {
|
||||||
|
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
|
}
|
||||||
|
conn, _, err := dialer.DialContext(ctx, c.wsURL, http.Header{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
if err = c.sendAndWaitCtrl("hi", map[string]any{"hi": map[string]any{"id": c.msgID("hi"), "ver": "0.22.8", "ua": "realtime-fanout-probe/1.0"}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.account.Token != "" {
|
||||||
|
if err = c.sendAndWaitCtrl("login", map[string]any{"login": map[string]any{"id": c.msgID("login"), "scheme": "token", "secret": c.account.Token}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
secret := base64.StdEncoding.EncodeToString([]byte(c.account.Username + ":" + c.account.Password))
|
||||||
|
if err = c.sendAndWaitCtrl("login", map[string]any{"login": map[string]any{"id": c.msgID("login"), "scheme": "basic", "secret": secret}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = c.sendAndWaitCtrl("sub", map[string]any{"sub": map[string]any{"id": c.msgID("sub"), "topic": topic, "get": map[string]any{"what": "desc"}}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go c.readLoop(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) sendAndWaitCtrl(name string, msg map[string]any) error {
|
||||||
|
c.sendMu.Lock()
|
||||||
|
if err := c.conn.WriteJSON(msg); err != nil {
|
||||||
|
c.sendMu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.sendMu.Unlock()
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := c.conn.ReadJSON(&raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ctrlRaw, ok := raw["ctrl"]; ok {
|
||||||
|
var ctrl struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(ctrlRaw, &ctrl)
|
||||||
|
if ctrl.Code >= 200 && ctrl.Code < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s ctrl code=%d text=%s", name, ctrl.Code, ctrl.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s ctrl timeout", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) readLoop(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := c.conn.ReadJSON(&raw); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := raw["ctrl"]; ok {
|
||||||
|
c.stats.acks.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataRaw, ok := raw["data"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(dataRaw, &data); err != nil || len(data.Content) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var content struct {
|
||||||
|
ProbeID string `json:"probe_id"`
|
||||||
|
SentAt int64 `json:"sent_at_unix_nano"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data.Content, &content); err != nil || content.ProbeID == "" || content.SentAt <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.stats.record(content.ProbeID, time.Since(time.Unix(0, content.SentAt)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishLoop(ctx context.Context, publishers []*client, topic string, rate int, duration time.Duration, stats *probeStats, done chan<- struct{}) {
|
||||||
|
defer close(done)
|
||||||
|
interval := time.Second / time.Duration(rate)
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Nanosecond
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
deadline := time.Now().Add(duration)
|
||||||
|
seq := int64(0)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
n := atomic.AddInt64(&seq, 1)
|
||||||
|
publisher := publishers[int(n)%len(publishers)]
|
||||||
|
probeID := fmt.Sprintf("probe-%d-%d", time.Now().UnixNano(), n)
|
||||||
|
msg := map[string]any{"pub": map[string]any{
|
||||||
|
"id": publisher.msgID("pub-" + strconv.FormatInt(n, 10)),
|
||||||
|
"topic": topic,
|
||||||
|
"head": map[string]any{"mime": "application/json"},
|
||||||
|
"content": map[string]any{
|
||||||
|
"probe_id": probeID,
|
||||||
|
"sent_at_unix_nano": time.Now().UnixNano(),
|
||||||
|
"seq": n,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
publisher.sendMu.Lock()
|
||||||
|
err := publisher.conn.WriteJSON(msg)
|
||||||
|
publisher.sendMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
stats.errors.Add(1)
|
||||||
|
log.Printf("publish failed client=%d seq=%d: %v", publisher.id, n, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.expected.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *probeStats) record(probeID string, latency time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.received.Add(1)
|
||||||
|
s.seen[probeID] = struct{}{}
|
||||||
|
s.latencies = append(s.latencies, latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSummary(stats *probeStats, activeSubscribers int) {
|
||||||
|
stats.mu.Lock()
|
||||||
|
latencies := append([]time.Duration(nil), stats.latencies...)
|
||||||
|
unique := len(stats.seen)
|
||||||
|
stats.mu.Unlock()
|
||||||
|
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||||
|
published := stats.expected.Load()
|
||||||
|
expectedDeliveries := published * int64(activeSubscribers)
|
||||||
|
received := stats.received.Load()
|
||||||
|
lost := expectedDeliveries - received
|
||||||
|
lossRate := 0.0
|
||||||
|
if expectedDeliveries > 0 {
|
||||||
|
lossRate = float64(lost) / float64(expectedDeliveries) * 100
|
||||||
|
}
|
||||||
|
fmt.Println("==== realtime fanout probe summary ====")
|
||||||
|
fmt.Printf("published=%d active_subscribers=%d expected_deliveries=%d received=%d lost=%d loss_rate=%.4f%% unique_messages_seen=%d errors=%d ctrl_acks=%d\n",
|
||||||
|
published, activeSubscribers, expectedDeliveries, received, lost, lossRate, unique, stats.errors.Load(), stats.acks.Load())
|
||||||
|
fmt.Printf("latency p50=%s p95=%s p99=%s max=%s samples=%d\n",
|
||||||
|
percentile(latencies, 50), percentile(latencies, 95), percentile(latencies, 99), maxDuration(latencies), len(latencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(values []time.Duration, p int) time.Duration {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
idx := int(math.Ceil(float64(p)/100*float64(len(values)))) - 1
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx >= len(values) {
|
||||||
|
idx = len(values) - 1
|
||||||
|
}
|
||||||
|
return values[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxDuration(values []time.Duration) time.Duration {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return values[len(values)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) msgID(suffix string) string {
|
||||||
|
return fmt.Sprintf("rtp-%d-%s", c.id, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEndpoints(raw string, apiKey string) []string {
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
endpoint := strings.TrimSpace(part)
|
||||||
|
if endpoint == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("bad ws endpoint %q: %v", endpoint, err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if apiKey != "" && q.Get("apikey") == "" {
|
||||||
|
q.Set("apikey", apiKey)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
out = append(out, u.String())
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
log.Fatal("no websocket endpoints")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAccounts(path string) ([]account, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
reader := csv.NewReader(bufio.NewReader(file))
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := make([]account, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if len(row) < 2 || strings.HasPrefix(strings.TrimSpace(row[0]), "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc := account{Username: strings.TrimSpace(row[0]), Password: strings.TrimSpace(row[1])}
|
||||||
|
if len(row) > 2 {
|
||||||
|
acc.Token = strings.TrimSpace(row[2])
|
||||||
|
}
|
||||||
|
accounts = append(accounts, acc)
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
@@ -259,9 +259,6 @@ type TenantHTTPInfrastructureAdapter interface {
|
|||||||
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
|
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
|
||||||
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
|
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
|
||||||
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error
|
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error
|
||||||
TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error
|
|
||||||
TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error)
|
|
||||||
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
|
|
||||||
TenantKafkaConsumeBegin(t.TenantID, *t.KafkaConsumeRecord, time.Time) (*t.KafkaConsumeRecord, bool, error)
|
TenantKafkaConsumeBegin(t.TenantID, *t.KafkaConsumeRecord, time.Time) (*t.KafkaConsumeRecord, bool, error)
|
||||||
TenantKafkaConsumeSucceed(t.TenantID, string, string, time.Time) error
|
TenantKafkaConsumeSucceed(t.TenantID, string, string, time.Time) error
|
||||||
TenantKafkaConsumeFail(t.TenantID, string, string, string, string, time.Time) (*t.KafkaConsumeRecord, error)
|
TenantKafkaConsumeFail(t.TenantID, string, string, string, string, time.Time) (*t.KafkaConsumeRecord, error)
|
||||||
@@ -375,6 +372,7 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error)
|
TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error)
|
||||||
TenantGroupSettingsGet(t.TenantID, string) (*t.GroupSettings, error)
|
TenantGroupSettingsGet(t.TenantID, string) (*t.GroupSettings, error)
|
||||||
TenantGroupSettingsUpdate(t.TenantID, string, t.GroupSettingsPatch) (*t.GroupSettings, error)
|
TenantGroupSettingsUpdate(t.TenantID, string, t.GroupSettingsPatch) (*t.GroupSettings, error)
|
||||||
|
TenantGroupDissolve(t.TenantID, string, t.Uid, string, time.Time) (*t.GroupSettings, error)
|
||||||
TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error)
|
TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error)
|
||||||
TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error)
|
TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error)
|
||||||
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error)
|
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error)
|
||||||
@@ -400,8 +398,6 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantGroupRoleUpdate(t.TenantID, string, t.Uid, t.Uid, string, t.AccessMode, time.Time) error
|
TenantGroupRoleUpdate(t.TenantID, string, t.Uid, t.Uid, string, t.AccessMode, time.Time) error
|
||||||
TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error
|
TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error
|
||||||
TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error)
|
TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error)
|
||||||
TenantGroupEventListPending(t.TenantID, int, time.Time) ([]t.GroupEvent, error)
|
|
||||||
TenantGroupEventMarkDelivered(t.TenantID, string, time.Time) error
|
|
||||||
|
|
||||||
TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error)
|
TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error)
|
||||||
TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error)
|
TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error)
|
||||||
|
|||||||
@@ -17,18 +17,6 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
|
|||||||
UNIQUE INDEX uk_im_http_idempotency_scope(user_id, method, route, idempotency_key),
|
UNIQUE INDEX uk_im_http_idempotency_scope(user_id, method, route, idempotency_key),
|
||||||
INDEX idx_im_http_idempotency_expiry(expires_at)
|
INDEX idx_im_http_idempotency_expiry(expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS im_business_events_outbox (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL,
|
|
||||||
target_type VARCHAR(16) NOT NULL, target VARCHAR(128) NOT NULL,
|
|
||||||
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL,
|
|
||||||
payload JSON, status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
|
|
||||||
next_retry_at DATETIME(3), delivered_at DATETIME(3),
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
||||||
PRIMARY KEY(id), UNIQUE INDEX uk_im_business_events_outbox_event(event_id),
|
|
||||||
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
|
|
||||||
INDEX idx_im_business_events_outbox_target(target_type, target, id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
|
`CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(96) NOT NULL,
|
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(96) NOT NULL,
|
||||||
consumer_group VARCHAR(128) NOT NULL, source_topic VARCHAR(128) NOT NULL,
|
consumer_group VARCHAR(128) NOT NULL, source_topic VARCHAR(128) NOT NULL,
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
|
|||||||
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
|
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(exec.statements) != 7 {
|
if len(exec.statements) != 6 {
|
||||||
t.Fatalf("created %d infrastructure tables, want 7", len(exec.statements))
|
t.Fatalf("created %d infrastructure tables, want 6", len(exec.statements))
|
||||||
}
|
}
|
||||||
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
|
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
|
||||||
for _, table := range []string{
|
for _, table := range []string{
|
||||||
"im_http_idempotency",
|
"im_http_idempotency",
|
||||||
"im_business_events_outbox",
|
|
||||||
"im_kafka_consume_log",
|
"im_kafka_consume_log",
|
||||||
"im_kafka_dead_letters",
|
"im_kafka_dead_letters",
|
||||||
"im_realname_appeals",
|
"im_realname_appeals",
|
||||||
@@ -35,4 +34,7 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
|
|||||||
if strings.Contains(joined, "im_kafka_outbox") {
|
if strings.Contains(joined, "im_kafka_outbox") {
|
||||||
t.Fatalf("kafka outbox table must not be created: %s", joined)
|
t.Fatalf("kafka outbox table must not be created: %s", joined)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(joined, "im_business_events_outbox") {
|
||||||
|
t.Fatalf("business event outbox table must not be created: %s", joined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,14 +424,10 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eventID := t.StableEventID("registration-invite-use", id, row.ID, store.DecodeUid(uid))
|
if err = tx.Commit(); err != nil {
|
||||||
if err = insertBusinessEventTx(tx, eventID, "user", uid.UserId(), uid, "registration.invite_used", map[string]any{
|
|
||||||
"tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid), "bound_user_id": row.BoundUserID,
|
|
||||||
"request_id": requestID, "used_at": now,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return a.TenantRegistrationInviteSyncUsage(id, row.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertFriendshipPairTx(tx *sqlx.Tx, uid1, uid2 t.Uid, topic string, now time.Time) error {
|
func upsertFriendshipPairTx(tx *sqlx.Tx, uid1, uid2 t.Uid, topic string, now time.Time) error {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#
|
#
|
||||||
# - Default tenant business database: tinode
|
# - Default tenant business database: tinode
|
||||||
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
|
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
|
||||||
# - im_http_idempotency and im_business_events_outbox for reliable HTTP writes
|
# - im_http_idempotency for HTTP write idempotency
|
||||||
# - im_file_review_tasks for asynchronous content safety and malware checks
|
# - im_file_review_tasks for asynchronous content safety and malware checks
|
||||||
# - business tables do not store tenant_id in independent-database mode
|
# - business tables do not store tenant_id in independent-database mode
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -2173,10 +2173,6 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, ac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"),
|
|
||||||
"topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2223,10 +2219,6 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"),
|
|
||||||
"topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2280,10 +2272,6 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, acto
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"),
|
|
||||||
"topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2336,10 +2324,6 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"),
|
|
||||||
"topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -3959,10 +3943,6 @@ func insertMeetingEventsTx(tx *sqlx.Tx, event principalMeetingEvent, participant
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := insertBusinessEventTx(tx, eventID, "user", participant.UserId(), event.Actor, event.EventType,
|
|
||||||
map[string]any{"meeting_id": event.MeetingID, "version": event.Version, "state": event.State}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -8964,10 +8944,6 @@ func (a *adapter) TenantFriendRequestCreate(id t.TenantID, req *t.FriendRequest)
|
|||||||
"新的好友申请", req.Message, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": req.ApplicantID.UserId()}); err != nil {
|
"新的好友申请", req.Message, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": req.ApplicantID.UserId()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, eventID, "user", req.TargetID.UserId(), req.ApplicantID, eventType,
|
|
||||||
map[string]any{"request_id": req.RequestID, "user_id": req.ApplicantID.UserId()}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -9319,14 +9295,6 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
|
|||||||
"好友申请已处理", t.FriendRequestAccepted, map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
|
"好友申请已处理", t.FriendRequestAccepted, map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, applicantEventID, "user", applicant.UserId(), actor, eventType,
|
|
||||||
map[string]any{"request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = insertBusinessEventTx(tx, t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted+":self", eventType, actor.UserId()),
|
|
||||||
"user", actor.UserId(), actor, eventType, map[string]any{"request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -9376,14 +9344,6 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac
|
|||||||
"好友申请已处理", status, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
|
"好友申请已处理", status, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = insertBusinessEventTx(tx, eventID, "user", req.ApplicantID.UserId(), actor, eventType,
|
|
||||||
map[string]any{"request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = insertBusinessEventTx(tx, t.StableEventID("relation", req.RequestID+":"+status+":self", eventType, actor.UserId()),
|
|
||||||
"user", actor.UserId(), actor, eventType, map[string]any{"request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,94 +140,6 @@ func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.H
|
|||||||
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil
|
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) TenantBusinessEventCreate(id t.TenantID, event *t.BusinessEvent) error {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if event == nil || event.EventID == "" || event.Target == "" || event.EventType == "" ||
|
|
||||||
(event.TargetType != "user" && event.TargetType != "topic") {
|
|
||||||
return t.ErrMalformed
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantBusinessEventCreate(id, event)
|
|
||||||
}
|
|
||||||
now := t.TimeNow()
|
|
||||||
_, err = a.db.Exec(`INSERT INTO im_business_events_outbox
|
|
||||||
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, event.EventID,
|
|
||||||
event.TargetType, event.Target, decodeUID(event.ActorUserID), event.EventType, event.Payload,
|
|
||||||
t.BusinessEventPending, now, now)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *adapter) TenantBusinessEventListPending(id t.TenantID, limit int, now time.Time) ([]t.BusinessEvent, error) {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantBusinessEventListPending(id, limit, now)
|
|
||||||
}
|
|
||||||
if limit <= 0 || limit > 500 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
rows, err := a.db.Queryx(`SELECT * FROM im_business_events_outbox
|
|
||||||
WHERE status=? AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, t.BusinessEventPending, now, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
events := make([]t.BusinessEvent, 0, limit)
|
|
||||||
for rows.Next() {
|
|
||||||
var row struct {
|
|
||||||
ID int64
|
|
||||||
EventID string `db:"event_id"`
|
|
||||||
TargetType string `db:"target_type"`
|
|
||||||
Target string
|
|
||||||
ActorUserID int64 `db:"actor_user_id"`
|
|
||||||
EventType string `db:"event_type"`
|
|
||||||
Payload []byte
|
|
||||||
Status string
|
|
||||||
Attempts int
|
|
||||||
NextRetryAt *time.Time `db:"next_retry_at"`
|
|
||||||
DeliveredAt *time.Time `db:"delivered_at"`
|
|
||||||
CreatedAt time.Time `db:"created_at"`
|
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
|
||||||
}
|
|
||||||
if err = rows.StructScan(&row); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
events = append(events, t.BusinessEvent{ID: row.ID, EventID: row.EventID, TargetType: row.TargetType,
|
|
||||||
Target: row.Target, ActorUserID: store.EncodeUid(row.ActorUserID), EventType: row.EventType,
|
|
||||||
Payload: row.Payload, Status: row.Status, Attempts: row.Attempts, NextRetryAt: row.NextRetryAt,
|
|
||||||
DeliveredAt: row.DeliveredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
|
|
||||||
}
|
|
||||||
return events, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if eventID == "" {
|
|
||||||
return t.ErrMalformed
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantBusinessEventMarkDelivered(id, eventID, now)
|
|
||||||
}
|
|
||||||
res, err := a.db.Exec(`UPDATE im_business_events_outbox SET status=?,delivered_at=?,attempts=attempts+1,updated_at=?
|
|
||||||
WHERE event_id=? AND status=?`, t.BusinessEventDelivered, now, now, eventID, t.BusinessEventPending)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count, _ := res.RowsAffected(); count == 0 {
|
|
||||||
return t.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *adapter) TenantKafkaConsumeBegin(id t.TenantID, record *t.KafkaConsumeRecord, deadline time.Time) (*t.KafkaConsumeRecord, bool, error) {
|
func (a *adapter) TenantKafkaConsumeBegin(id t.TenantID, record *t.KafkaConsumeRecord, deadline time.Time) (*t.KafkaConsumeRecord, bool, error) {
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
tenantAdp, err := a.infrastructureAdapter(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -600,19 +512,6 @@ func truncateKafkaString(value string, limit int) string {
|
|||||||
return string(runes[:limit])
|
return string(runes[:limit])
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error {
|
|
||||||
encoded, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := t.TimeNow()
|
|
||||||
_, err = tx.Exec(`INSERT INTO im_business_events_outbox
|
|
||||||
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventID, targetType,
|
|
||||||
target, decodeUID(actor), eventType, encoded, t.BusinessEventPending, now, now)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error {
|
func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error {
|
||||||
encoded, err := json.Marshal(action)
|
encoded, err := json.Marshal(action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestGroupBusinessTablesCreated(t *testing.T) {
|
|||||||
tables := []string{
|
tables := []string{
|
||||||
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
|
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
|
||||||
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
|
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
|
||||||
"im_group_files", "im_group_admin_logs", "im_group_idempotency", "im_group_events_outbox",
|
"im_group_files", "im_group_admin_logs", "im_group_idempotency",
|
||||||
}
|
}
|
||||||
args := make([]any, len(tables))
|
args := make([]any, len(tables))
|
||||||
for i, table := range tables {
|
for i, table := range tables {
|
||||||
@@ -90,7 +90,7 @@ func TestGroupBusinessTablesCreated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPInfrastructureTablesCreated(t *testing.T) {
|
func TestHTTPInfrastructureTablesCreated(t *testing.T) {
|
||||||
tables := []string{"im_http_idempotency", "im_business_events_outbox"}
|
tables := []string{"im_http_idempotency"}
|
||||||
args := make([]any, len(tables))
|
args := make([]any, len(tables))
|
||||||
for i, table := range tables {
|
for i, table := range tables {
|
||||||
args[i] = table
|
args[i] = table
|
||||||
|
|||||||
+37
-145
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinode/chat/server/logs"
|
"github.com/tinode/chat/server/logs"
|
||||||
@@ -11,10 +10,6 @@ import (
|
|||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const imExternalBusinessEventPollInterval = time.Second
|
|
||||||
|
|
||||||
var imBusinessEventDispatchLocks sync.Map
|
|
||||||
|
|
||||||
func stableIMEventID(parts ...any) string {
|
func stableIMEventID(parts ...any) string {
|
||||||
return types.StableEventID(parts...)
|
return types.StableEventID(parts...)
|
||||||
}
|
}
|
||||||
@@ -28,14 +23,44 @@ func enqueueIMBusinessEvent(tenantID types.TenantID, eventID, targetType, target
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = store.HTTPInfrastructure.CreateEvent(tenantID, &types.BusinessEvent{
|
return publishIMBusinessRealtimeEvent(tenantID, targetType, target, actor, eventType, encoded)
|
||||||
EventID: eventID, TargetType: targetType, Target: target, ActorUserID: actor,
|
}
|
||||||
EventType: eventType, Payload: encoded,
|
|
||||||
})
|
func publishIMBusinessRealtimeEvent(tenantID types.TenantID, targetType, target string, actor types.Uid, eventType string, payload json.RawMessage) error {
|
||||||
if err == nil {
|
from := ""
|
||||||
dispatchIMBusinessEvents(tenantID)
|
if !actor.IsZero() {
|
||||||
|
from = actor.UserId()
|
||||||
}
|
}
|
||||||
return err
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
message := &ServerComMessage{
|
||||||
|
TenantID: tenantID, RcptTo: target, AsUser: from, Timestamp: time.Now().UTC().Round(time.Millisecond),
|
||||||
|
Info: &MsgServerInfo{Topic: target, From: from, What: "im", Event: eventType, Payload: payload},
|
||||||
|
}
|
||||||
|
switch targetType {
|
||||||
|
case "user":
|
||||||
|
uid := types.ParseUserId(target)
|
||||||
|
if uid.IsZero() {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
if globals.sessionStore != nil {
|
||||||
|
globals.sessionStore.DeliverToUser(tenantID, uid, "", message)
|
||||||
|
}
|
||||||
|
publishRealtimeUserMessage(uid, message)
|
||||||
|
case "topic":
|
||||||
|
if globals.hub != nil {
|
||||||
|
select {
|
||||||
|
case globals.hub.routeSrv <- message:
|
||||||
|
default:
|
||||||
|
logs.Warn.Printf("business realtime: hub queue full target=%s event=%s", strings.TrimSpace(target), eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishRealtimeTopicMessage(target, message)
|
||||||
|
default:
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, eventType,
|
func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, eventType,
|
||||||
@@ -87,136 +112,3 @@ func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, st
|
|||||||
return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType,
|
return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType,
|
||||||
map[string]any{"user_id": actor.UserId()})
|
map[string]any{"user_id": actor.UserId()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
|
||||||
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lockValue, _ := imBusinessEventDispatchLocks.LoadOrStore(tenantID, &sync.Mutex{})
|
|
||||||
lock := lockValue.(*sync.Mutex)
|
|
||||||
lock.Lock()
|
|
||||||
defer lock.Unlock()
|
|
||||||
|
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
|
||||||
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
|
|
||||||
if err != nil {
|
|
||||||
logs.Warn.Printf("business outbox: list tenant=%d: %v", tenantID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, event := range events {
|
|
||||||
if event.EventType == "registration.invite_used" {
|
|
||||||
var payload struct {
|
|
||||||
InviteCodeID int64 `json:"invite_code_id"`
|
|
||||||
}
|
|
||||||
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.InviteCodeID <= 0 {
|
|
||||||
logs.Warn.Printf("business outbox: malformed invite usage event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = store.RegistrationInvites.SyncUsage(tenantID, payload.InviteCodeID); err != nil {
|
|
||||||
logs.Warn.Printf("business outbox: sync invite usage event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
|
||||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if event.EventType == "security.password_reset" {
|
|
||||||
var payload struct {
|
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
}
|
|
||||||
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.UserID <= 0 {
|
|
||||||
logs.Warn.Printf("business outbox: malformed password reset event=%s: %v", event.EventID, err)
|
|
||||||
} else if globals.sessionStore != nil {
|
|
||||||
globals.sessionStore.EvictUser(store.EncodeUid(payload.UserID), "")
|
|
||||||
}
|
|
||||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
|
||||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
from := ""
|
|
||||||
if !event.ActorUserID.IsZero() {
|
|
||||||
from = event.ActorUserID.UserId()
|
|
||||||
}
|
|
||||||
payload := json.RawMessage(event.Payload)
|
|
||||||
if len(payload) == 0 {
|
|
||||||
payload = json.RawMessage(`{}`)
|
|
||||||
}
|
|
||||||
message := &ServerComMessage{
|
|
||||||
TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt,
|
|
||||||
Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload},
|
|
||||||
}
|
|
||||||
if deliverIMBusinessEventToUserSessions(tenantID, event, message) {
|
|
||||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
|
||||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case globals.hub.routeSrv <- message:
|
|
||||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
|
||||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
logs.Warn.Printf("business outbox: hub queue full event=%s target=%s", event.EventID, strings.TrimSpace(event.Target))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startIMExternalBusinessEventPoller bridges direct database writers until the
|
|
||||||
// same event contract is transported by Kafka.
|
|
||||||
func startIMExternalBusinessEventPoller() func() {
|
|
||||||
stop := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(imExternalBusinessEventPollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
if globals.sessionStore == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tenantIDs := make(map[types.TenantID]struct{})
|
|
||||||
globals.sessionStore.Range(func(_ string, session *Session) bool {
|
|
||||||
if session != nil && !session.tenantID.IsZero() {
|
|
||||||
tenantIDs[session.tenantID] = struct{}{}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
for tenantID := range tenantIDs {
|
|
||||||
dispatchIMBusinessEvents(tenantID)
|
|
||||||
}
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return func() { close(stop) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func deliverIMBusinessEventToUserSessions(tenantID types.TenantID, event types.BusinessEvent, message *ServerComMessage) bool {
|
|
||||||
if event.TargetType != "user" || globals.sessionStore == nil || message == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
uid := types.ParseUserId(event.Target)
|
|
||||||
if uid.IsZero() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
delivered := false
|
|
||||||
globals.sessionStore.Range(func(_ string, session *Session) bool {
|
|
||||||
if session == nil || session.uid != uid || session.tenantID != tenantID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if session.queueOut(message.copy()) {
|
|
||||||
delivered = true
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return delivered
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tinode/chat/server/store"
|
|
||||||
"github.com/tinode/chat/server/store/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type passwordResetEventStore struct {
|
|
||||||
testHTTPInfrastructureStore
|
|
||||||
delivered []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *passwordResetEventStore) MarkEventDelivered(_ types.TenantID, eventID string, _ time.Time) error {
|
|
||||||
s.delivered = append(s.delivered, eventID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatchPasswordResetEvent(t *testing.T) {
|
|
||||||
oldInfrastructure := store.HTTPInfrastructure
|
|
||||||
oldHub, oldSessions := globals.hub, globals.sessionStore
|
|
||||||
defer func() {
|
|
||||||
store.HTTPInfrastructure = oldInfrastructure
|
|
||||||
globals.hub, globals.sessionStore = oldHub, oldSessions
|
|
||||||
}()
|
|
||||||
|
|
||||||
infrastructure := &passwordResetEventStore{}
|
|
||||||
infrastructure.events = []types.BusinessEvent{{
|
|
||||||
EventID: "password-reset-1", EventType: "security.password_reset",
|
|
||||||
TargetType: "user", Target: "42", Payload: []byte(`{"user_id":42}`),
|
|
||||||
}}
|
|
||||||
store.HTTPInfrastructure = infrastructure
|
|
||||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
|
||||||
globals.sessionStore = nil
|
|
||||||
|
|
||||||
dispatchIMBusinessEvents(types.TenantID(1))
|
|
||||||
|
|
||||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "password-reset-1" {
|
|
||||||
t.Fatalf("password reset event was not marked delivered: %v", infrastructure.delivered)
|
|
||||||
}
|
|
||||||
if len(globals.hub.routeSrv) != 0 {
|
|
||||||
t.Fatal("password reset event was routed as a regular client event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatchRegistrationInviteUsageEvent(t *testing.T) {
|
|
||||||
oldInfrastructure, oldInvites := store.HTTPInfrastructure, store.RegistrationInvites
|
|
||||||
oldHub := globals.hub
|
|
||||||
defer func() {
|
|
||||||
store.HTTPInfrastructure, store.RegistrationInvites = oldInfrastructure, oldInvites
|
|
||||||
globals.hub = oldHub
|
|
||||||
}()
|
|
||||||
|
|
||||||
infrastructure := &passwordResetEventStore{}
|
|
||||||
infrastructure.events = []types.BusinessEvent{{
|
|
||||||
EventID: "invite-use-1", EventType: "registration.invite_used",
|
|
||||||
TargetType: "user", Target: "42", Payload: []byte(`{"invite_code_id":99}`),
|
|
||||||
}}
|
|
||||||
invites := &testRegistrationInviteStore{}
|
|
||||||
store.HTTPInfrastructure = infrastructure
|
|
||||||
store.RegistrationInvites = invites
|
|
||||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
|
||||||
|
|
||||||
dispatchIMBusinessEvents(types.TenantID(1))
|
|
||||||
|
|
||||||
if invites.syncedCode != 99 {
|
|
||||||
t.Fatalf("invite usage projection code=%d, want 99", invites.syncedCode)
|
|
||||||
}
|
|
||||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "invite-use-1" {
|
|
||||||
t.Fatalf("invite usage event was not marked delivered: %v", infrastructure.delivered)
|
|
||||||
}
|
|
||||||
if len(globals.hub.routeSrv) != 0 {
|
|
||||||
t.Fatal("invite usage event was routed as a regular client event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatchUserBusinessEventToOnlineSession(t *testing.T) {
|
|
||||||
oldInfrastructure := store.HTTPInfrastructure
|
|
||||||
oldHub, oldSessions := globals.hub, globals.sessionStore
|
|
||||||
defer func() {
|
|
||||||
store.HTTPInfrastructure = oldInfrastructure
|
|
||||||
globals.hub, globals.sessionStore = oldHub, oldSessions
|
|
||||||
}()
|
|
||||||
|
|
||||||
uid := types.ParseUserId("usrv97xq86jw3c")
|
|
||||||
infrastructure := &passwordResetEventStore{}
|
|
||||||
infrastructure.events = []types.BusinessEvent{{
|
|
||||||
EventID: "friend-request-1", EventType: "friend.request.created",
|
|
||||||
TargetType: "user", Target: uid.UserId(), Payload: []byte(`{"request_id":"frq_1","user_id":"usrSrc"}`),
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
}}
|
|
||||||
store.HTTPInfrastructure = infrastructure
|
|
||||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
|
||||||
globals.sessionStore = NewSessionStore(time.Minute)
|
|
||||||
session := &Session{sid: "s1", proto: WEBSOCK, uid: uid, tenantID: types.TenantID(1), send: make(chan any, 1)}
|
|
||||||
globals.sessionStore.sessCache[session.sid] = session
|
|
||||||
|
|
||||||
dispatchIMBusinessEvents(types.TenantID(1))
|
|
||||||
|
|
||||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "friend-request-1" {
|
|
||||||
t.Fatalf("business event was not marked delivered: %v", infrastructure.delivered)
|
|
||||||
}
|
|
||||||
if len(globals.hub.routeSrv) != 0 {
|
|
||||||
t.Fatal("online user event should be delivered directly to session without topic routing")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case raw := <-session.send:
|
|
||||||
msg, ok := raw.(*ServerComMessage)
|
|
||||||
if !ok || msg.Info == nil {
|
|
||||||
t.Fatalf("unexpected outbound message: %#v", raw)
|
|
||||||
}
|
|
||||||
if msg.Info.What != "im" || msg.Info.Event != "friend.request.created" || msg.Info.Topic != uid.UserId() {
|
|
||||||
t.Fatalf("unexpected info event: %#v", msg.Info)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatal("online user session did not receive business info event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+38
-61
@@ -2,80 +2,57 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinode/chat/server/logs"
|
"github.com/tinode/chat/server/logs"
|
||||||
"github.com/tinode/chat/server/store"
|
|
||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dispatchIMGroupEvents delivers committed outbox events to online topic
|
func publishIMGroupRealtimeEvent(tenantID types.TenantID, topic string, actor types.Uid, eventType string, payload any) {
|
||||||
// sessions. Events remain pending when the hub is unavailable or its queue is
|
topic = strings.TrimSpace(topic)
|
||||||
// full, so the next successful group mutation retries them.
|
eventType = strings.TrimSpace(eventType)
|
||||||
func dispatchIMGroupEvents(tenantID types.TenantID) {
|
if tenantID.IsZero() || topic == "" || eventType == "" {
|
||||||
if tenantID.IsZero() || globals.hub == nil || store.GroupBusiness == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
encoded, err := json.Marshal(payload)
|
||||||
events, err := store.GroupBusiness.ListPendingEvents(tenantID, 100, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logs.Warn.Printf("group outbox: list tenant=%d: %v", tenantID, err)
|
logs.Warn.Printf("group realtime: encode event topic=%s event=%s: %v", topic, eventType, err)
|
||||||
return
|
encoded = []byte(`{}`)
|
||||||
}
|
}
|
||||||
for _, event := range events {
|
from := ""
|
||||||
members, memberErr := store.Topics.GetUsers(tenantID, event.Topic, &types.QueryOpt{Limit: 1000})
|
if !actor.IsZero() {
|
||||||
if memberErr != nil {
|
from = actor.UserId()
|
||||||
logs.Warn.Printf("group outbox: list members event=%s: %v", event.EventID, memberErr)
|
}
|
||||||
return
|
message := &ServerComMessage{
|
||||||
}
|
TenantID: tenantID,
|
||||||
for _, member := range members {
|
RcptTo: topic,
|
||||||
uid := types.ParseUid(member.User)
|
AsUser: from,
|
||||||
if uid.IsZero() {
|
Timestamp: time.Now().UTC().Round(time.Millisecond),
|
||||||
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
|
Info: &MsgServerInfo{
|
||||||
uid = store.EncodeUid(decoded)
|
Topic: topic,
|
||||||
}
|
From: from,
|
||||||
}
|
What: "group",
|
||||||
if uid.IsZero() || uid == event.ActorUserID {
|
Event: eventType,
|
||||||
continue
|
Payload: json.RawMessage(encoded),
|
||||||
}
|
},
|
||||||
if err = createIMSystemNotification(tenantID, uid,
|
}
|
||||||
stableIMEventID(event.EventID, uid.UserId(), "notification"), event.EventType,
|
if globals.hub != nil {
|
||||||
"群聊动态", event.EventType, types.KVMap{"topic": event.Topic}); err != nil {
|
|
||||||
logs.Warn.Printf("group outbox: create notification event=%s user=%s: %v", event.EventID, uid.UserId(), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload := json.RawMessage(event.Payload)
|
|
||||||
if len(payload) == 0 {
|
|
||||||
payload = json.RawMessage(`{}`)
|
|
||||||
}
|
|
||||||
from := ""
|
|
||||||
if !event.ActorUserID.IsZero() {
|
|
||||||
from = event.ActorUserID.UserId()
|
|
||||||
}
|
|
||||||
message := &ServerComMessage{
|
|
||||||
TenantID: tenantID,
|
|
||||||
RcptTo: event.Topic,
|
|
||||||
AsUser: from,
|
|
||||||
Timestamp: event.CreatedAt,
|
|
||||||
Info: &MsgServerInfo{
|
|
||||||
Topic: event.Topic,
|
|
||||||
From: from,
|
|
||||||
What: "group",
|
|
||||||
Event: event.EventType,
|
|
||||||
Payload: payload,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case globals.hub.routeSrv <- message:
|
case globals.hub.routeSrv <- message:
|
||||||
if err = store.GroupBusiness.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
|
||||||
logs.Warn.Printf("group outbox: mark delivered event=%s: %v", event.EventID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
logs.Warn.Printf("group outbox: hub queue full event=%s", event.EventID)
|
logs.Warn.Printf("group realtime: hub queue full topic=%s event=%s", topic, eventType)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
publishRealtimeTopicMessage(topic, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userIDs(uids []types.Uid) []string {
|
||||||
|
ids := make([]string, 0, len(uids))
|
||||||
|
for _, uid := range uids {
|
||||||
|
if !uid.IsZero() {
|
||||||
|
ids = append(ids, uid.UserId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,18 +115,7 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var internalOutboxResourceSpecs = map[string]*internalQuerySpec{
|
var internalOutboxResourceSpecs = map[string]*internalQuerySpec{}
|
||||||
"business-events": {
|
|
||||||
Select: "SELECT id,event_id,target_type,target,actor_user_id,event_type,payload,status,attempts,next_retry_at,delivered_at,created_at,updated_at",
|
|
||||||
From: "FROM im_business_events_outbox", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
|
||||||
Filters: []internalQueryFilter{{"event_type", "event_type", "=", "string"}, {"status", "status", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target", "target", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
|
|
||||||
},
|
|
||||||
"group-events": {
|
|
||||||
Select: "SELECT id,event_id,topic,actor_user_id,event_type,payload,status,attempts,next_retry_at,delivered_at,created_at,updated_at",
|
|
||||||
From: "FROM im_group_events_outbox", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
|
||||||
Filters: []internalQueryFilter{{"event_type", "event_type", "=", "string"}, {"status", "status", "=", "string"}, {"topic", "topic", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var internalOrgResourceSpecs = map[string]*internalQuerySpec{
|
var internalOrgResourceSpecs = map[string]*internalQuerySpec{
|
||||||
"departments": {
|
"departments": {
|
||||||
@@ -184,8 +173,6 @@ func serveInternalDashboardSummary(wrt http.ResponseWriter, req *http.Request, t
|
|||||||
summary := map[string]any{}
|
summary := map[string]any{}
|
||||||
queries := map[string]string{
|
queries := map[string]string{
|
||||||
"dead_kafka": "SELECT COUNT(*) AS count FROM im_kafka_dead_letters WHERE status='dead'",
|
"dead_kafka": "SELECT COUNT(*) AS count FROM im_kafka_dead_letters WHERE status='dead'",
|
||||||
"pending_business": "SELECT COUNT(*) AS count FROM im_business_events_outbox WHERE status='pending'",
|
|
||||||
"pending_group": "SELECT COUNT(*) AS count FROM im_group_events_outbox WHERE status='pending'",
|
|
||||||
"pending_realname": "SELECT COUNT(*) AS count FROM im_realname_verifications WHERE status='pending'",
|
"pending_realname": "SELECT COUNT(*) AS count FROM im_realname_verifications WHERE status='pending'",
|
||||||
"received_abuse": "SELECT COUNT(*) AS count FROM im_abuse_reports WHERE status='received'",
|
"received_abuse": "SELECT COUNT(*) AS count FROM im_abuse_reports WHERE status='received'",
|
||||||
"received_feedback": "SELECT COUNT(*) AS count FROM im_feedback_submissions WHERE status='received'",
|
"received_feedback": "SELECT COUNT(*) AS count FROM im_feedback_submissions WHERE status='received'",
|
||||||
|
|||||||
+89
-1
@@ -98,7 +98,7 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Method == http.MethodPost && !(len(parts) == 3 && parts[2] == "users") {
|
if req.Method == http.MethodPost && !isInternalAdminPostRoute(parts) {
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
|
|||||||
serveInternalAdminResourceList(wrt, req, tenant.ID, "group-messages", "")
|
serveInternalAdminResourceList(wrt, req, tenant.ID, "group-messages", "")
|
||||||
case req.Method == http.MethodPost && len(parts) == 3 && parts[2] == "users":
|
case req.Method == http.MethodPost && len(parts) == 3 && parts[2] == "users":
|
||||||
serveInternalUserCreate(wrt, req, tenant.ID)
|
serveInternalUserCreate(wrt, req, tenant.ID)
|
||||||
|
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
|
||||||
|
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
|
||||||
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
|
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
|
||||||
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
|
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
|
||||||
case len(parts) == 4 && parts[2] == "users":
|
case len(parts) == 4 && parts[2] == "users":
|
||||||
@@ -163,6 +165,11 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isInternalAdminPostRoute(parts []string) bool {
|
||||||
|
return (len(parts) == 3 && parts[2] == "users") ||
|
||||||
|
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action")
|
||||||
|
}
|
||||||
|
|
||||||
type internalUserCreateRequest struct {
|
type internalUserCreateRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
@@ -170,6 +177,15 @@ type internalUserCreateRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type internalUserActionRequest struct {
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
ActorUserID string `json:"actor_user_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Hard bool `json:"hard"`
|
||||||
|
}
|
||||||
|
|
||||||
func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
|
func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
|
||||||
var body internalUserCreateRequest
|
var body internalUserCreateRequest
|
||||||
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
|
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
@@ -244,6 +260,78 @@ func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantI
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveInternalUserAction(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, rawUserID string) {
|
||||||
|
var body internalUserActionRequest
|
||||||
|
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventType := strings.TrimSpace(body.EventType)
|
||||||
|
if !isInternalUserActionSupported(eventType) {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "unsupported user action", map[string]any{"event_type": eventType})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := parseKafkaCommandUser(rawUserID)
|
||||||
|
if target.IsZero() {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "用户ID不能为空", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := adminCommandPayload{
|
||||||
|
CommandID: firstNonEmpty(strings.TrimSpace(req.Header.Get("X-Request-ID")), "internal-"+strconv.FormatInt(time.Now().UTC().UnixNano(), 10)),
|
||||||
|
ActorUserID: strings.TrimSpace(body.ActorUserID),
|
||||||
|
UserID: strconv.FormatInt(internalAdminUIDRaw(target), 10),
|
||||||
|
TargetID: strconv.FormatInt(internalAdminUIDRaw(target), 10),
|
||||||
|
DeviceID: strings.TrimSpace(body.DeviceID),
|
||||||
|
PasswordHash: strings.TrimSpace(body.PasswordHash),
|
||||||
|
Reason: strings.TrimSpace(body.Reason),
|
||||||
|
Hard: body.Hard,
|
||||||
|
}
|
||||||
|
envelope := kafkaEnvelope{
|
||||||
|
EventID: payload.CommandID,
|
||||||
|
TraceID: strings.TrimSpace(req.Header.Get("X-Request-ID")),
|
||||||
|
EventType: eventType,
|
||||||
|
BizType: "user",
|
||||||
|
BizID: strconv.FormatInt(internalAdminUIDRaw(target), 10),
|
||||||
|
OccurredAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
result map[string]any
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if eventType == adminCommandUserPasswordReset {
|
||||||
|
result, err = executeKafkaUserPasswordResetCommand(tenantID, payload)
|
||||||
|
} else {
|
||||||
|
result, err = executeKafkaUserGovernanceCommand(tenantID, envelope, payload, parseKafkaCommandUser(payload.ActorUserID))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalQueryError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := enqueueKafkaAuditEvent(req.Context(), tenantID, "admin", parseKafkaCommandUser(payload.ActorUserID),
|
||||||
|
eventType, "internal_user_action", payload.CommandID, "succeeded", "", result); err != nil {
|
||||||
|
logs.Warn.Printf("internal user action audit enqueue failed tenant:%d user:%d event:%s err:%v", tenantID, internalAdminUIDRaw(target), eventType, err)
|
||||||
|
}
|
||||||
|
writeIMSuccess(wrt, req, map[string]any{
|
||||||
|
"user_id": internalAdminUIDRaw(target),
|
||||||
|
"event_type": eventType,
|
||||||
|
"status": "succeeded",
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInternalUserActionSupported(eventType string) bool {
|
||||||
|
switch strings.TrimSpace(eventType) {
|
||||||
|
case adminCommandUserBan, adminCommandUserUnban,
|
||||||
|
adminCommandUserFreeze, adminCommandUserUnfreeze,
|
||||||
|
adminCommandUserDeviceKick, adminCommandUserLogoutAll,
|
||||||
|
adminCommandUserCancel, adminCommandUserDelete,
|
||||||
|
adminCommandUserPasswordReset:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
|
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
|
||||||
if !internalAPI.config.Enabled {
|
if !internalAPI.config.Enabled {
|
||||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
adminCommandGroupMemberUnmute = "group.member.unmute"
|
adminCommandGroupMemberUnmute = "group.member.unmute"
|
||||||
adminCommandGroupMemberRemove = "group.member.remove"
|
adminCommandGroupMemberRemove = "group.member.remove"
|
||||||
adminCommandGroupOwnerTransfer = "group.owner.transfer"
|
adminCommandGroupOwnerTransfer = "group.owner.transfer"
|
||||||
|
adminCommandGroupDissolve = "group.dissolve"
|
||||||
adminCommandUserBan = "user.ban"
|
adminCommandUserBan = "user.ban"
|
||||||
adminCommandUserUnban = "user.unban"
|
adminCommandUserUnban = "user.unban"
|
||||||
adminCommandUserFreeze = "user.freeze"
|
adminCommandUserFreeze = "user.freeze"
|
||||||
@@ -75,6 +76,8 @@ type groupGovernancePatch struct {
|
|||||||
MemberUploadFiles *bool `json:"member_upload_files"`
|
MemberUploadFiles *bool `json:"member_upload_files"`
|
||||||
MemberPrivateChat *bool `json:"member_private_chat"`
|
MemberPrivateChat *bool `json:"member_private_chat"`
|
||||||
MemberInvite *bool `json:"member_invite"`
|
MemberInvite *bool `json:"member_invite"`
|
||||||
|
MaxMemberCount *int `json:"max_member_count"`
|
||||||
|
MaxAdminCount *int `json:"max_admin_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminCommandResultPayload struct {
|
type adminCommandResultPayload struct {
|
||||||
@@ -221,7 +224,8 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.settings.updated",
|
||||||
|
map[string]any{"topic": topic, "version": settings.Version})
|
||||||
return map[string]any{"topic": topic, "version": settings.Version, "updated_at": settings.UpdatedAt}, nil
|
return map[string]any{"topic": topic, "version": settings.Version, "updated_at": settings.UpdatedAt}, nil
|
||||||
|
|
||||||
case adminCommandGroupGovernUpdate:
|
case adminCommandGroupGovernUpdate:
|
||||||
@@ -231,12 +235,15 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
MemberUploadFiles: payload.Governance.MemberUploadFiles,
|
MemberUploadFiles: payload.Governance.MemberUploadFiles,
|
||||||
MemberPrivateChat: payload.Governance.MemberPrivateChat,
|
MemberPrivateChat: payload.Governance.MemberPrivateChat,
|
||||||
MemberInvite: payload.Governance.MemberInvite,
|
MemberInvite: payload.Governance.MemberInvite,
|
||||||
|
MaxMemberCount: payload.Governance.MaxMemberCount,
|
||||||
|
MaxAdminCount: payload.Governance.MaxAdminCount,
|
||||||
Version: payload.Version,
|
Version: payload.Version,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.settings.updated",
|
||||||
|
map[string]any{"topic": topic, "version": settings.Version})
|
||||||
return map[string]any{"topic": topic, "version": settings.Version, "updated_at": settings.UpdatedAt}, nil
|
return map[string]any{"topic": topic, "version": settings.Version, "updated_at": settings.UpdatedAt}, nil
|
||||||
|
|
||||||
case adminCommandGroupAdminSet:
|
case adminCommandGroupAdminSet:
|
||||||
@@ -259,7 +266,8 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
}
|
}
|
||||||
succeeded = append(succeeded, map[string]any{"user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleAdmin})
|
succeeded = append(succeeded, map[string]any{"user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleAdmin})
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.role.updated",
|
||||||
|
map[string]any{"topic": topic, "succeeded": succeeded})
|
||||||
return map[string]any{"topic": topic, "succeeded": succeeded}, nil
|
return map[string]any{"topic": topic, "succeeded": succeeded}, nil
|
||||||
|
|
||||||
case adminCommandGroupAdminRemove:
|
case adminCommandGroupAdminRemove:
|
||||||
@@ -271,7 +279,8 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
types.GroupMemberRoleMember, types.ModeCPublic, now); err != nil {
|
types.GroupMemberRoleMember, types.ModeCPublic, now); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.role.updated",
|
||||||
|
map[string]any{"user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleMember})
|
||||||
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleMember}, nil
|
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleMember}, nil
|
||||||
|
|
||||||
case adminCommandGroupMemberMute:
|
case adminCommandGroupMemberMute:
|
||||||
@@ -291,7 +300,8 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.mute.updated",
|
||||||
|
map[string]any{"user_id": internalAdminUIDRaw(target), "muted": true, "version": mute.Version})
|
||||||
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "muted": true, "version": mute.Version}, nil
|
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "muted": true, "version": mute.Version}, nil
|
||||||
|
|
||||||
case adminCommandGroupMemberUnmute:
|
case adminCommandGroupMemberUnmute:
|
||||||
@@ -302,7 +312,8 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
if err := store.GroupBusiness.DeleteMute(tenantID, topic, target, actor, now); err != nil {
|
if err := store.GroupBusiness.DeleteMute(tenantID, topic, target, actor, now); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.mute.updated",
|
||||||
|
map[string]any{"user_id": internalAdminUIDRaw(target), "muted": false})
|
||||||
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "muted": false}, nil
|
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "muted": false}, nil
|
||||||
|
|
||||||
case adminCommandGroupMemberRemove:
|
case adminCommandGroupMemberRemove:
|
||||||
@@ -313,7 +324,8 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
if err := store.GroupBusiness.Leave(tenantID, topic, target, now); err != nil {
|
if err := store.GroupBusiness.Leave(tenantID, topic, target, now); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.member.left",
|
||||||
|
map[string]any{"user_id": internalAdminUIDRaw(target)})
|
||||||
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "status": "left"}, nil
|
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "status": "left"}, nil
|
||||||
|
|
||||||
case adminCommandGroupOwnerTransfer:
|
case adminCommandGroupOwnerTransfer:
|
||||||
@@ -324,12 +336,32 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
if err := store.GroupBusiness.TransferOwner(tenantID, topic, actor, target, payload.Version, now); err != nil {
|
if err := store.GroupBusiness.TransferOwner(tenantID, topic, actor, target, payload.Version, now); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(tenantID)
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.owner.transferred",
|
||||||
|
map[string]any{"owner": internalAdminUIDRaw(target), "previous_owner": internalAdminUIDRaw(actor)})
|
||||||
return map[string]any{"topic": topic, "owner": internalAdminUIDRaw(target), "previous_owner": internalAdminUIDRaw(actor)}, nil
|
return map[string]any{"topic": topic, "owner": internalAdminUIDRaw(target), "previous_owner": internalAdminUIDRaw(actor)}, nil
|
||||||
|
|
||||||
|
case adminCommandGroupDissolve:
|
||||||
|
if err := dissolveGroupByAdminCommand(tenantID, topic, actor, strings.TrimSpace(payload.Reason), now); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
publishIMGroupRealtimeEvent(tenantID, topic, actor, "group.dissolved",
|
||||||
|
map[string]any{"topic": topic, "reason": strings.TrimSpace(payload.Reason)})
|
||||||
|
return map[string]any{"topic": topic, "status": "dissolved"}, nil
|
||||||
}
|
}
|
||||||
return nil, errors.New("unsupported admin command: " + envelope.EventType)
|
return nil, errors.New("unsupported admin command: " + envelope.EventType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dissolveGroupByAdminCommand(tenantID types.TenantID, topic string, actor types.Uid, reason string, now time.Time) error {
|
||||||
|
if strings.TrimSpace(topic) == "" || actor.IsZero() {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
if store.GroupBusiness == nil {
|
||||||
|
return errors.New("group business store is not initialized")
|
||||||
|
}
|
||||||
|
_, err := store.GroupBusiness.Dissolve(tenantID, topic, actor, reason, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func executeKafkaMessageAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload) (map[string]any, error) {
|
func executeKafkaMessageAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload) (map[string]any, error) {
|
||||||
conversationType := strings.TrimSpace(payload.ConversationType)
|
conversationType := strings.TrimSpace(payload.ConversationType)
|
||||||
topic := strings.TrimSpace(payload.Topic)
|
topic := strings.TrimSpace(payload.Topic)
|
||||||
@@ -386,7 +418,7 @@ func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEn
|
|||||||
if err := store.Users.UpdateState(tenantID, target, types.StateSuspended); err != nil {
|
if err := store.Users.UpdateState(tenantID, target, types.StateSuspended); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
evictUserSessions(target)
|
evictUserSessions(tenantID, target)
|
||||||
notifyUserStateChanged(tenantID, target, types.StateSuspended)
|
notifyUserStateChanged(tenantID, target, types.StateSuspended)
|
||||||
result["state"] = types.StateSuspended.String()
|
result["state"] = types.StateSuspended.String()
|
||||||
result["evicted"] = true
|
result["evicted"] = true
|
||||||
@@ -407,6 +439,7 @@ func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEn
|
|||||||
if globals.sessionStore != nil {
|
if globals.sessionStore != nil {
|
||||||
evicted = globals.sessionStore.EvictUserDevice(target, deviceID)
|
evicted = globals.sessionStore.EvictUserDevice(target, deviceID)
|
||||||
}
|
}
|
||||||
|
publishRealtimeEvictDevice(tenantID, target, deviceID, "admin_device_kick")
|
||||||
if store.Devices != nil {
|
if store.Devices != nil {
|
||||||
if err := store.Devices.Delete(tenantID, target, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
|
if err := store.Devices.Delete(tenantID, target, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -416,7 +449,7 @@ func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEn
|
|||||||
result["evicted_sessions"] = evicted
|
result["evicted_sessions"] = evicted
|
||||||
return result, nil
|
return result, nil
|
||||||
case adminCommandUserLogoutAll:
|
case adminCommandUserLogoutAll:
|
||||||
evictUserSessions(target)
|
evictUserSessions(tenantID, target)
|
||||||
result["evicted"] = true
|
result["evicted"] = true
|
||||||
return result, nil
|
return result, nil
|
||||||
case adminCommandUserCancel, adminCommandUserDelete:
|
case adminCommandUserCancel, adminCommandUserDelete:
|
||||||
@@ -456,7 +489,7 @@ func executeKafkaUserPasswordResetCommand(tenantID types.TenantID, payload admin
|
|||||||
}
|
}
|
||||||
authVersion = version
|
authVersion = version
|
||||||
}
|
}
|
||||||
evictUserSessions(target)
|
evictUserSessions(tenantID, target)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"user_id": internalAdminUIDRaw(target),
|
"user_id": internalAdminUIDRaw(target),
|
||||||
"action": adminCommandUserPasswordReset,
|
"action": adminCommandUserPasswordReset,
|
||||||
@@ -465,10 +498,11 @@ func executeKafkaUserPasswordResetCommand(tenantID types.TenantID, payload admin
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func evictUserSessions(uid types.Uid) {
|
func evictUserSessions(tenantID types.TenantID, uid types.Uid) {
|
||||||
if globals.sessionStore != nil {
|
if globals.sessionStore != nil {
|
||||||
globals.sessionStore.EvictUser(uid, "")
|
globals.sessionStore.EvictUser(uid, "")
|
||||||
}
|
}
|
||||||
|
publishRealtimeEvictUser(tenantID, uid, "admin_user_evict", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyUserStateChanged(tenantID types.TenantID, uid types.Uid, state types.ObjState) {
|
func notifyUserStateChanged(tenantID types.TenantID, uid types.Uid, state types.ObjState) {
|
||||||
@@ -479,7 +513,7 @@ func notifyUserStateChanged(tenantID types.TenantID, uid types.Uid, state types.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteUserByAdminCommand(tenantID types.TenantID, uid types.Uid, hard bool, _ time.Time) error {
|
func deleteUserByAdminCommand(tenantID types.TenantID, uid types.Uid, hard bool, _ time.Time) error {
|
||||||
evictUserSessions(uid)
|
evictUserSessions(tenantID, uid)
|
||||||
usersRemoveUser(tenantID, uid)
|
usersRemoveUser(tenantID, uid)
|
||||||
if globals.hub != nil && globals.hub.unreg != nil {
|
if globals.hub != nil && globals.hub.unreg != nil {
|
||||||
done := make(chan bool)
|
done := make(chan bool)
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ func TestEnqueueKafkaWorkbenchEventRunsSynchronously(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
kafkaBus = &kafkaRuntime{}
|
kafkaBus = &kafkaRuntime{}
|
||||||
infrastructure := &testHTTPInfrastructureStore{}
|
|
||||||
security := &testSecurityStore{}
|
security := &testSecurityStore{}
|
||||||
store.HTTPInfrastructure = infrastructure
|
|
||||||
store.Security = security
|
store.Security = security
|
||||||
|
|
||||||
err := enqueueKafkaWorkbenchEvent(context.Background(), 7, types.Uid(42), "meeting", "meeting.created", "meeting-1",
|
err := enqueueKafkaWorkbenchEvent(context.Background(), 7, types.Uid(42), "meeting", "meeting.created", "meeting-1",
|
||||||
@@ -144,7 +142,4 @@ func TestEnqueueKafkaWorkbenchEventRunsSynchronously(t *testing.T) {
|
|||||||
if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" {
|
if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" {
|
||||||
t.Fatalf("unexpected notification: %#v", security.notifications[0])
|
t.Fatalf("unexpected notification: %#v", security.notifications[0])
|
||||||
}
|
}
|
||||||
if len(infrastructure.events) != 2 {
|
|
||||||
t.Fatalf("expected business events for both targets, got %#v", infrastructure.events)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -146,6 +146,8 @@ var globals struct {
|
|||||||
sessionStore *SessionStore
|
sessionStore *SessionStore
|
||||||
// Cluster data.
|
// Cluster data.
|
||||||
cluster *Cluster
|
cluster *Cluster
|
||||||
|
// Unique node ID used by Redis realtime fanout when native clustering is disabled.
|
||||||
|
nodeID string
|
||||||
// gRPC server.
|
// gRPC server.
|
||||||
grpcServer *grpc.Server
|
grpcServer *grpc.Server
|
||||||
// Plugins.
|
// Plugins.
|
||||||
@@ -346,6 +348,8 @@ type configType struct {
|
|||||||
ExpvarPath string `json:"expvar"`
|
ExpvarPath string `json:"expvar"`
|
||||||
// URL path for internal server status. Disabled if the path is blank.
|
// URL path for internal server status. Disabled if the path is blank.
|
||||||
ServerStatusPath string `json:"server_status"`
|
ServerStatusPath string `json:"server_status"`
|
||||||
|
// Unique IM node ID for Redis realtime fanout. Defaults to IM_NODE_ID or HOSTNAME.
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
// Take IP address of the client from HTTP header 'X-Forwarded-For'.
|
// Take IP address of the client from HTTP header 'X-Forwarded-For'.
|
||||||
// Useful when tinode is behind a proxy. If missing, fallback to default RemoteAddr.
|
// Useful when tinode is behind a proxy. If missing, fallback to default RemoteAddr.
|
||||||
UseXForwardedFor bool `json:"use_x_forwarded_for"`
|
UseXForwardedFor bool `json:"use_x_forwarded_for"`
|
||||||
@@ -466,6 +470,7 @@ func main() {
|
|||||||
// Initialize cluster and receive calculated workerId.
|
// Initialize cluster and receive calculated workerId.
|
||||||
// Cluster won't be started here yet.
|
// Cluster won't be started here yet.
|
||||||
workerId := clusterInit(config.Cluster, clusterSelf)
|
workerId := clusterInit(config.Cluster, clusterSelf)
|
||||||
|
globals.nodeID = resolveIMNodeID(config.NodeID)
|
||||||
|
|
||||||
if *pprofFile != "" {
|
if *pprofFile != "" {
|
||||||
*pprofFile = toAbsolutePath(curwd, *pprofFile)
|
*pprofFile = toAbsolutePath(curwd, *pprofFile)
|
||||||
@@ -740,14 +745,12 @@ func main() {
|
|||||||
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
|
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
|
||||||
// The hub (the main message router)
|
// The hub (the main message router)
|
||||||
globals.hub = newHub()
|
globals.hub = newHub()
|
||||||
startRedisGroupFanout(context.Background())
|
startRedisRealtimeFanout(context.Background())
|
||||||
startKafkaAdminCommandConsumer()
|
startKafkaAdminCommandConsumer()
|
||||||
startKafkaConfigEventConsumer()
|
startKafkaConfigEventConsumer()
|
||||||
startKafkaCacheInvalidateConsumer()
|
startKafkaCacheInvalidateConsumer()
|
||||||
startKafkaMessagePersistConsumer()
|
startKafkaMessagePersistConsumer()
|
||||||
startKafkaDeadLetterConsumer()
|
startKafkaDeadLetterConsumer()
|
||||||
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
|
|
||||||
defer stopExternalBusinessEvents()
|
|
||||||
|
|
||||||
// Start accepting cluster traffic.
|
// Start accepting cluster traffic.
|
||||||
if globals.cluster != nil {
|
if globals.cluster != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"permanent_accounts": false,
|
"permanent_accounts": false,
|
||||||
"expvar": "/debug/vars",
|
"expvar": "/debug/vars",
|
||||||
"server_status": "-",
|
"server_status": "-",
|
||||||
|
"node_id": "",
|
||||||
"use_x_forwarded_for": true,
|
"use_x_forwarded_for": true,
|
||||||
"x_frame_options": "SAMEORIGIN",
|
"x_frame_options": "SAMEORIGIN",
|
||||||
"default_country_code": "CN",
|
"default_country_code": "CN",
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ var (
|
|||||||
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
|
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
|
||||||
}, []string{"driver", "operation", "status"})
|
}, []string{"driver", "operation", "status"})
|
||||||
|
|
||||||
|
RealtimeFanoutEventsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "realtime_fanout_events_total",
|
||||||
|
Help: "Total number of realtime fanout events by stage, kind, target type, and status.",
|
||||||
|
}, []string{"stage", "kind", "target_type", "status"})
|
||||||
|
|
||||||
|
RealtimeFanoutDeliveryDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "realtime_fanout_delivery_duration_seconds",
|
||||||
|
Help: "Realtime fanout delivery age measured from message timestamp to local dispatch.",
|
||||||
|
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||||
|
}, []string{"kind", "target_type"})
|
||||||
|
|
||||||
WSConnectionsActive = prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
WSConnectionsActive = prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "ws_connections_active",
|
Name: "ws_connections_active",
|
||||||
Help: "Current active WebSocket connections.",
|
Help: "Current active WebSocket connections.",
|
||||||
@@ -37,7 +48,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Registry.MustRegister(WSConnectionsActive, HTTPRequestsTotal, HTTPRequestDuration, DBQueryDuration)
|
Registry.MustRegister(WSConnectionsActive, HTTPRequestsTotal, HTTPRequestDuration, DBQueryDuration,
|
||||||
|
RealtimeFanoutEventsTotal, RealtimeFanoutDeliveryDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ObserveDB(driverName, operation string, started time.Time, err error) {
|
func ObserveDB(driverName, operation string, started time.Time, err error) {
|
||||||
@@ -48,6 +60,26 @@ func ObserveDB(driverName, operation string, started time.Time, err error) {
|
|||||||
DBQueryDuration.WithLabelValues(driverName, operation, status).Observe(time.Since(started).Seconds())
|
DBQueryDuration.WithLabelValues(driverName, operation, status).Observe(time.Since(started).Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IncRealtimeFanout(stage, kind, targetType, status string) {
|
||||||
|
RealtimeFanoutEventsTotal.WithLabelValues(normalizeLabel(stage), normalizeLabel(kind),
|
||||||
|
normalizeLabel(targetType), normalizeLabel(status)).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ObserveRealtimeFanoutDelivery(kind, targetType string, timestamp time.Time) {
|
||||||
|
if timestamp.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RealtimeFanoutDeliveryDuration.WithLabelValues(normalizeLabel(kind), normalizeLabel(targetType)).
|
||||||
|
Observe(time.Since(timestamp).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLabel(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func expvarIntValue(name string) int64 {
|
func expvarIntValue(name string) int64 {
|
||||||
value := expvar.Get(name)
|
value := expvar.Get(name)
|
||||||
if value == nil {
|
if value == nil {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tinode/chat/server/logs"
|
||||||
|
immetrics "github.com/tinode/chat/server/metrics"
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
realtimeKindMessage = "message"
|
||||||
|
realtimeKindSessionControl = "session_control"
|
||||||
|
|
||||||
|
realtimeTargetTopic = "topic"
|
||||||
|
realtimeTargetUser = "user"
|
||||||
|
realtimeTargetDevice = "device"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startRedisRealtimeFanout(ctx context.Context) {
|
||||||
|
if err := store.StartRealtimeFanoutSubscriber(ctx, currentNodeID(), deliverRealtimeFanoutEnvelope); err != nil {
|
||||||
|
immetrics.IncRealtimeFanout("subscribe", realtimeKindMessage, realtimeTargetTopic, "error")
|
||||||
|
logs.Warn.Println("redis realtime fanout: subscriber disabled", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("subscribe", realtimeKindMessage, realtimeTargetTopic, "ok")
|
||||||
|
logs.Info.Printf("redis realtime fanout: subscriber started node=%s", currentNodeID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishRealtimeTopicMessage(topic string, msg *ServerComMessage) {
|
||||||
|
if msg == nil || !msg.TenantID.IsValid() || strings.TrimSpace(topic) == "" {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetTopic, "skip_invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if globals.cluster != nil {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetTopic, "skip_native_cluster")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dedupKey := realtimeMessageDedupKey(topic, msg)
|
||||||
|
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetTopic, topic, msg.SkipSid, dedupKey, msg); err != nil {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetTopic, "error")
|
||||||
|
logs.Warn.Printf("redis realtime fanout: publish topic=%s failed: %v", topic, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetTopic, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishRealtimeUserMessage(uid types.Uid, msg *ServerComMessage) {
|
||||||
|
if msg == nil || !msg.TenantID.IsValid() || uid.IsZero() {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "skip_invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dedupKey := realtimeMessageDedupKey(uid.UserId(), msg)
|
||||||
|
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetUser, uid.UserId(), msg.SkipSid, dedupKey, msg); err != nil {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "error")
|
||||||
|
logs.Warn.Printf("redis realtime fanout: publish user=%s failed: %v", uid.UserId(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishRealtimeEvictUser(tenantID types.TenantID, uid types.Uid, reason string, skipSid string) {
|
||||||
|
if !tenantID.IsValid() || uid.IsZero() {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetUser, "skip_invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := NoErrEvicted("", "", types.TimeNow())
|
||||||
|
msg.TenantID = tenantID
|
||||||
|
msg.AsUser = uid.UserId()
|
||||||
|
msg.SkipSid = skipSid
|
||||||
|
if reason != "" {
|
||||||
|
msg.Ctrl.Params = map[string]any{"reason": reason}
|
||||||
|
}
|
||||||
|
dedupKey := "evict:user:" + uid.UserId() + ":" + reason
|
||||||
|
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetUser, uid.UserId(), skipSid, dedupKey, msg); err != nil {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetUser, "error")
|
||||||
|
logs.Warn.Printf("redis realtime fanout: publish evict user=%s failed: %v", uid.UserId(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetUser, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishRealtimeEvictDevice(tenantID types.TenantID, uid types.Uid, deviceID, reason string) {
|
||||||
|
if !tenantID.IsValid() || uid.IsZero() || strings.TrimSpace(deviceID) == "" {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetDevice, "skip_invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := NoErrEvicted("", "", types.TimeNow())
|
||||||
|
msg.TenantID = tenantID
|
||||||
|
msg.AsUser = uid.UserId()
|
||||||
|
if reason != "" {
|
||||||
|
msg.Ctrl.Params = map[string]any{"reason": reason}
|
||||||
|
}
|
||||||
|
target := uid.UserId() + ":" + strings.TrimSpace(deviceID)
|
||||||
|
dedupKey := "evict:device:" + target + ":" + reason
|
||||||
|
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetDevice, target, "", dedupKey, msg); err != nil {
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetDevice, "error")
|
||||||
|
logs.Warn.Printf("redis realtime fanout: publish evict device=%s failed: %v", target, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetDevice, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverRealtimeFanoutEnvelope(envelope *store.RealtimeFanoutEnvelope) {
|
||||||
|
if envelope == nil || !envelope.TenantID.IsValid() || len(envelope.Message) == 0 {
|
||||||
|
immetrics.IncRealtimeFanout("receive", realtimeKindMessage, realtimeTargetTopic, "skip_invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("receive", envelope.Kind, envelope.TargetType, "ok")
|
||||||
|
var msg ServerComMessage
|
||||||
|
if err := json.Unmarshal(envelope.Message, &msg); err != nil {
|
||||||
|
immetrics.IncRealtimeFanout("receive", envelope.Kind, envelope.TargetType, "malformed")
|
||||||
|
logs.Warn.Println("redis realtime fanout: malformed message", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.TenantID = envelope.TenantID
|
||||||
|
msg.RcptTo = envelope.Target
|
||||||
|
msg.SkipSid = envelope.SkipSid
|
||||||
|
msg.sess = nil
|
||||||
|
prepareRedisGroupFanoutMessage(&msg, envelope.Target)
|
||||||
|
|
||||||
|
switch envelope.Kind {
|
||||||
|
case realtimeKindSessionControl:
|
||||||
|
deliverRealtimeSessionControl(envelope, &msg)
|
||||||
|
default:
|
||||||
|
immetrics.ObserveRealtimeFanoutDelivery(envelope.Kind, envelope.TargetType, msg.Timestamp)
|
||||||
|
deliverRealtimeMessage(envelope, &msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverRealtimeMessage(envelope *store.RealtimeFanoutEnvelope, msg *ServerComMessage) {
|
||||||
|
if globals.sessionStore == nil {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_no_session_store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch envelope.TargetType {
|
||||||
|
case realtimeTargetUser:
|
||||||
|
uid := types.ParseUserId(envelope.Target)
|
||||||
|
if uid.IsZero() {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delivered := globals.sessionStore.DeliverToUser(envelope.TenantID, uid, envelope.SkipSid, msg)
|
||||||
|
recordRealtimeFanoutDeliverResult(envelope.Kind, envelope.TargetType, delivered)
|
||||||
|
case realtimeTargetTopic, "":
|
||||||
|
deliverRealtimeTopicMessage(envelope, msg)
|
||||||
|
default:
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_unknown_target_type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverRealtimeTopicMessage(envelope *store.RealtimeFanoutEnvelope, msg *ServerComMessage) {
|
||||||
|
topic := strings.TrimSpace(envelope.Target)
|
||||||
|
if topic == "" {
|
||||||
|
topic = strings.TrimSpace(msg.RcptTo)
|
||||||
|
}
|
||||||
|
if topic == "" {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, realtimeTargetTopic, "skip_invalid_target")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.RcptTo = topic
|
||||||
|
key := types.TopicKey{TenantID: envelope.TenantID, Topic: topic}
|
||||||
|
if globals.hub != nil && globals.hub.topicGet(key) != nil {
|
||||||
|
select {
|
||||||
|
case globals.hub.routeSrv <- msg:
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, realtimeTargetTopic, "hub_queued")
|
||||||
|
default:
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, realtimeTargetTopic, "hub_full")
|
||||||
|
logs.Err.Println("redis realtime fanout: hub routeSrv queue full", topic)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if types.GetTopicCat(topic) == types.TopicCatP2P {
|
||||||
|
delivered := globals.sessionStore.DeliverToP2PTopic(envelope.TenantID, topic, envelope.SkipSid, msg)
|
||||||
|
recordRealtimeFanoutDeliverResult(envelope.Kind, realtimeTargetTopic, delivered)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delivered := globals.sessionStore.DeliverToTopicSubscribers(envelope.TenantID, topic, envelope.SkipSid, msg)
|
||||||
|
recordRealtimeFanoutDeliverResult(envelope.Kind, realtimeTargetTopic, delivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverRealtimeSessionControl(envelope *store.RealtimeFanoutEnvelope, msg *ServerComMessage) {
|
||||||
|
if globals.sessionStore == nil {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_no_session_store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch envelope.TargetType {
|
||||||
|
case realtimeTargetDevice:
|
||||||
|
uidText, deviceID, ok := strings.Cut(envelope.Target, ":")
|
||||||
|
if !ok {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globals.sessionStore.EvictUserDevice(types.ParseUserId(uidText), deviceID)
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
|
||||||
|
case realtimeTargetUser:
|
||||||
|
uid := types.ParseUserId(envelope.Target)
|
||||||
|
if uid.IsZero() {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globals.sessionStore.EvictUser(uid, envelope.SkipSid)
|
||||||
|
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
|
||||||
|
}
|
||||||
|
_ = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordRealtimeFanoutDeliverResult(kind, targetType string, delivered int) {
|
||||||
|
if delivered > 0 {
|
||||||
|
immetrics.IncRealtimeFanout("deliver", kind, targetType, "local_delivered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
immetrics.IncRealtimeFanout("deliver", kind, targetType, "no_local_session")
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeMessageDedupKey(target string, msg *ServerComMessage) string {
|
||||||
|
if msg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if msg.Data != nil && msg.Data.SeqId > 0 {
|
||||||
|
topic := target
|
||||||
|
if msg.Data.Topic != "" {
|
||||||
|
topic = msg.Data.Topic
|
||||||
|
}
|
||||||
|
return "data:" + topic + ":" + strconv.FormatInt(msg.Data.SeqId, 10)
|
||||||
|
}
|
||||||
|
if msg.Info != nil {
|
||||||
|
return "info:" + target + ":" + msg.Info.Event + ":" + strconv.FormatInt(msg.Info.SeqId, 10)
|
||||||
|
}
|
||||||
|
if msg.Pres != nil {
|
||||||
|
return "pres:" + target + ":" + msg.Pres.What + ":" + strconv.FormatInt(msg.Pres.SeqId, 10)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRealtimeEnvelopeDeliversToUserSessions(t *testing.T) {
|
||||||
|
oldStore := globals.sessionStore
|
||||||
|
defer func() { globals.sessionStore = oldStore }()
|
||||||
|
|
||||||
|
tenantID := types.TenantID(1)
|
||||||
|
uid := types.Uid(1001)
|
||||||
|
globals.sessionStore = realtimeTestSessionStore(
|
||||||
|
realtimeTestSession("sid-1", tenantID, uid, ""),
|
||||||
|
realtimeTestSession("sid-2", tenantID, uid, ""),
|
||||||
|
realtimeTestSession("sid-other", tenantID, types.Uid(1002), ""),
|
||||||
|
)
|
||||||
|
msg := &ServerComMessage{
|
||||||
|
Data: &MsgServerData{Topic: uid.UserId(), From: types.Uid(1002).UserId(), SeqId: 12, Timestamp: time.Now()},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Kind: realtimeKindMessage,
|
||||||
|
TargetType: realtimeTargetUser,
|
||||||
|
Target: uid.UserId(),
|
||||||
|
Message: raw,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, sid := range []string{"sid-1", "sid-2"} {
|
||||||
|
sess := globals.sessionStore.Get(sid)
|
||||||
|
select {
|
||||||
|
case got := <-sess.send:
|
||||||
|
if got.(*ServerComMessage).TenantID != tenantID {
|
||||||
|
t.Fatalf("tenant not restored for %s", sid)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("expected delivery to %s", sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(globals.sessionStore.Get("sid-other").send) != 0 {
|
||||||
|
t.Fatal("unexpected delivery to other user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeP2PTopicFallbackRewritesVisibleTopic(t *testing.T) {
|
||||||
|
oldStore := globals.sessionStore
|
||||||
|
oldHub := globals.hub
|
||||||
|
defer func() {
|
||||||
|
globals.sessionStore = oldStore
|
||||||
|
globals.hub = oldHub
|
||||||
|
}()
|
||||||
|
|
||||||
|
tenantID := types.TenantID(1)
|
||||||
|
uid1 := types.Uid(1001)
|
||||||
|
uid2 := types.Uid(1002)
|
||||||
|
topic := uid1.P2PName(uid2)
|
||||||
|
if topic == "" {
|
||||||
|
t.Fatal("failed to create p2p topic")
|
||||||
|
}
|
||||||
|
globals.hub = nil
|
||||||
|
globals.sessionStore = realtimeTestSessionStore(
|
||||||
|
realtimeTestSession("sid-u1", tenantID, uid1, uid2.UserId()),
|
||||||
|
realtimeTestSession("sid-u2", tenantID, uid2, uid1.UserId()),
|
||||||
|
)
|
||||||
|
msg := &ServerComMessage{
|
||||||
|
Data: &MsgServerData{Topic: topic, From: uid1.UserId(), SeqId: 13, Timestamp: time.Now()},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Kind: realtimeKindMessage,
|
||||||
|
TargetType: realtimeTargetTopic,
|
||||||
|
Target: topic,
|
||||||
|
Message: raw,
|
||||||
|
})
|
||||||
|
|
||||||
|
expectTopic := map[string]string{"sid-u1": uid2.UserId(), "sid-u2": uid1.UserId()}
|
||||||
|
for sid, visibleTopic := range expectTopic {
|
||||||
|
sess := globals.sessionStore.Get(sid)
|
||||||
|
select {
|
||||||
|
case got := <-sess.send:
|
||||||
|
data := got.(*ServerComMessage).Data
|
||||||
|
if data.Topic != visibleTopic {
|
||||||
|
t.Fatalf("%s got topic %q, want %q", sid, data.Topic, visibleTopic)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("expected p2p delivery to %s", sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeTestSessionStore(sessions ...*Session) *SessionStore {
|
||||||
|
ss := NewSessionStore(time.Minute)
|
||||||
|
for _, sess := range sessions {
|
||||||
|
ss.sessCache[sess.sid] = sess
|
||||||
|
}
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeTestSession(sid string, tenantID types.TenantID, uid types.Uid, sub string) *Session {
|
||||||
|
sess := &Session{
|
||||||
|
sid: sid,
|
||||||
|
tenantID: tenantID,
|
||||||
|
uid: uid,
|
||||||
|
send: make(chan any, 4),
|
||||||
|
subs: make(map[string]*Subscription),
|
||||||
|
}
|
||||||
|
if sub != "" {
|
||||||
|
sess.subs[sub] = &Subscription{}
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -1375,6 +1376,22 @@ func currentNodeID() string {
|
|||||||
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
|
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
|
||||||
return globals.cluster.thisNodeName
|
return globals.cluster.thisNodeName
|
||||||
}
|
}
|
||||||
|
if globals.nodeID != "" {
|
||||||
|
return globals.nodeID
|
||||||
|
}
|
||||||
|
return resolveIMNodeID("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveIMNodeID(configured string) string {
|
||||||
|
if configured = strings.TrimSpace(configured); configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("IM_NODE_ID")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("HOSTNAME")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
return "local"
|
return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,90 @@ func (ss *SessionStore) Range(f func(sid string, s *Session) bool) {
|
|||||||
ss.lock.Unlock()
|
ss.lock.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ss *SessionStore) DeliverToUser(tenantID types.TenantID, uid types.Uid, skipSid string, msg *ServerComMessage) int {
|
||||||
|
if ss == nil || !tenantID.IsValid() || uid.IsZero() || msg == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
delivered := 0
|
||||||
|
ss.Range(func(_ string, sess *Session) bool {
|
||||||
|
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != tenantID || sess.uid != uid || sess.sid == skipSid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sess.queueOut(msg.copy()) {
|
||||||
|
delivered++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ss *SessionStore) DeliverToTopicSubscribers(tenantID types.TenantID, topic, skipSid string, msg *ServerComMessage) int {
|
||||||
|
if ss == nil || !tenantID.IsValid() || topic == "" || msg == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
delivered := 0
|
||||||
|
ss.Range(func(_ string, sess *Session) bool {
|
||||||
|
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != tenantID || sess.sid == skipSid || !sess.hasSub(topic) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msgCopy := msg.copy()
|
||||||
|
if msgCopy.RcptTo == "" {
|
||||||
|
msgCopy.RcptTo = topic
|
||||||
|
}
|
||||||
|
if msgCopy.Data != nil && msgCopy.Data.Topic == "" {
|
||||||
|
msgCopy.Data.Topic = topic
|
||||||
|
}
|
||||||
|
if sess.queueOut(msgCopy) {
|
||||||
|
delivered++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ss *SessionStore) DeliverToP2PTopic(tenantID types.TenantID, topic, skipSid string, msg *ServerComMessage) int {
|
||||||
|
if ss == nil || !tenantID.IsValid() || topic == "" || msg == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
uid1, uid2, err := types.ParseP2P(topic)
|
||||||
|
if err != nil {
|
||||||
|
return ss.DeliverToTopicSubscribers(tenantID, topic, skipSid, msg)
|
||||||
|
}
|
||||||
|
participants := map[types.Uid]struct{}{uid1: {}, uid2: {}}
|
||||||
|
delivered := 0
|
||||||
|
ss.Range(func(_ string, sess *Session) bool {
|
||||||
|
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != tenantID || sess.sid == skipSid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := participants[sess.uid]; !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
viewTopic, err := types.P2PNameForUser(sess.uid, topic)
|
||||||
|
if err != nil || viewTopic == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !sess.hasSub(topic) && !sess.hasSub(viewTopic) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msgCopy := msg.copy()
|
||||||
|
msgCopy.RcptTo = sess.uid.UserId()
|
||||||
|
if msgCopy.Data != nil {
|
||||||
|
msgCopy.Data.Topic = viewTopic
|
||||||
|
}
|
||||||
|
if msgCopy.Info != nil {
|
||||||
|
msgCopy.Info.Topic = viewTopic
|
||||||
|
}
|
||||||
|
if msgCopy.Pres != nil {
|
||||||
|
msgCopy.Pres.Topic = viewTopic
|
||||||
|
}
|
||||||
|
if sess.queueOut(msgCopy) {
|
||||||
|
delivered++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return delivered
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown terminates sessionStore. No need to clean up.
|
// Shutdown terminates sessionStore. No need to clean up.
|
||||||
// Don't send to clustered sessions, their servers are not being shut down.
|
// Don't send to clustered sessions, their servers are not being shut down.
|
||||||
func (ss *SessionStore) Shutdown() {
|
func (ss *SessionStore) Shutdown() {
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ type groupFanoutEnvelope struct {
|
|||||||
Message json.RawMessage `json:"message"`
|
Message json.RawMessage `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RealtimeFanoutEnvelope struct {
|
||||||
|
OriginNodeID string `json:"origin_node_id"`
|
||||||
|
TenantID types.TenantID `json:"tenant_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
SkipSid string `json:"skip_sid,omitempty"`
|
||||||
|
DedupKey string `json:"dedup_key,omitempty"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
type groupMessageRuntime interface {
|
type groupMessageRuntime interface {
|
||||||
Close() error
|
Close() error
|
||||||
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
|
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
|
||||||
@@ -71,6 +82,8 @@ type groupMessageRuntime interface {
|
|||||||
GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error)
|
GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error)
|
||||||
PublishGroupFanout(originNodeID string, message any) error
|
PublishGroupFanout(originNodeID string, message any) error
|
||||||
StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error
|
StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error
|
||||||
|
PublishRealtimeFanout(originNodeID string, tenantID types.TenantID, kind, targetType, target, skipSid, dedupKey string, message any) error
|
||||||
|
StartRealtimeFanoutSubscriber(ctx context.Context, nodeID string, handler func(*RealtimeFanoutEnvelope)) error
|
||||||
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
|
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
|
||||||
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
|
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
|
||||||
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
|
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
|
||||||
@@ -237,6 +250,20 @@ func StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func
|
|||||||
return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler)
|
return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PublishRealtimeFanout(originNodeID string, tenantID types.TenantID, kind, targetType, target, skipSid, dedupKey string, message any) error {
|
||||||
|
if groupMsgRuntime == nil || originNodeID == "" || !tenantID.IsValid() || targetType == "" || target == "" || message == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.PublishRealtimeFanout(originNodeID, tenantID, kind, targetType, target, skipSid, dedupKey, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartRealtimeFanoutSubscriber(ctx context.Context, nodeID string, handler func(*RealtimeFanoutEnvelope)) error {
|
||||||
|
if groupMsgRuntime == nil || nodeID == "" || handler == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.StartRealtimeFanoutSubscriber(ctx, nodeID, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
|
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
|
||||||
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -761,28 +788,49 @@ func (r *redisGroupMessageRuntime) GetTopicNodeOnlineMembers(tenantID types.Tena
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error {
|
func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error {
|
||||||
|
return r.PublishRealtimeFanout(originNodeID, types.ZeroTenantID, "message", "topic", "", "", "", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) PublishRealtimeFanout(originNodeID string, tenantID types.TenantID, kind, targetType, target, skipSid, dedupKey string, message any) error {
|
||||||
if !r.cfg.GroupFanout {
|
if !r.cfg.GroupFanout {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if originNodeID == "" || message == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
payload, err := json.Marshal(message)
|
payload, err := json.Marshal(message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
envelope, err := json.Marshal(groupFanoutEnvelope{
|
envelope, err := json.Marshal(RealtimeFanoutEnvelope{
|
||||||
OriginNodeID: originNodeID,
|
OriginNodeID: originNodeID,
|
||||||
|
TenantID: tenantID,
|
||||||
|
Kind: kind,
|
||||||
|
TargetType: targetType,
|
||||||
|
Target: target,
|
||||||
|
SkipSid: skipSid,
|
||||||
|
DedupKey: dedupKey,
|
||||||
Message: payload,
|
Message: payload,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.client.Publish(context.Background(), r.groupFanoutChannel(), envelope).Err()
|
return r.client.Publish(context.Background(), r.realtimeFanoutChannel(tenantID), envelope).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
|
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
|
||||||
|
return r.StartRealtimeFanoutSubscriber(ctx, nodeID, func(envelope *RealtimeFanoutEnvelope) {
|
||||||
|
if envelope != nil {
|
||||||
|
handler(envelope.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) StartRealtimeFanoutSubscriber(ctx context.Context, nodeID string, handler func(*RealtimeFanoutEnvelope)) error {
|
||||||
if !r.cfg.GroupFanout {
|
if !r.cfg.GroupFanout {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
pubsub := r.client.Subscribe(ctx, r.groupFanoutChannel())
|
pubsub := r.client.PSubscribe(ctx, r.realtimeFanoutPattern())
|
||||||
if _, err := pubsub.Receive(ctx); err != nil {
|
if _, err := pubsub.Receive(ctx); err != nil {
|
||||||
_ = pubsub.Close()
|
_ = pubsub.Close()
|
||||||
return err
|
return err
|
||||||
@@ -798,11 +846,27 @@ func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Contex
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var envelope groupFanoutEnvelope
|
var envelope RealtimeFanoutEnvelope
|
||||||
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 {
|
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
handler(envelope.Message)
|
if envelope.TenantID.IsValid() && envelope.DedupKey != "" {
|
||||||
|
id := fmt.Sprintf("%d:%s", envelope.TenantID, envelope.DedupKey)
|
||||||
|
claimed, err := r.ClaimShortState("rt_dedup", id, 5*time.Minute)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if envelope.TargetType == "" {
|
||||||
|
envelope.TargetType = "topic"
|
||||||
|
}
|
||||||
|
if envelope.Kind == "" {
|
||||||
|
envelope.Kind = "message"
|
||||||
|
}
|
||||||
|
if !envelope.TenantID.IsValid() && envelope.Target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handler(&envelope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -1035,6 +1099,17 @@ func (r *redisGroupMessageRuntime) groupFanoutChannel() string {
|
|||||||
return r.key(types.ZeroTenantID, "group_fanout")
|
return r.key(types.ZeroTenantID, "group_fanout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) realtimeFanoutChannel(tenantID types.TenantID) string {
|
||||||
|
if tenantID.IsValid() {
|
||||||
|
return r.key(tenantID, "rt", "fanout")
|
||||||
|
}
|
||||||
|
return r.groupFanoutChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) realtimeFanoutPattern() string {
|
||||||
|
return strings.TrimSuffix(r.prefix, ":") + ":*:rt:fanout"
|
||||||
|
}
|
||||||
|
|
||||||
func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string {
|
func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string {
|
||||||
return r.key(tenantID, "cache", namespace, id)
|
return r.key(tenantID, "cache", namespace, id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ func (r *testGroupMessageRuntime) StartGroupFanoutSubscriber(context.Context, st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *testGroupMessageRuntime) PublishRealtimeFanout(string, types.TenantID, string, string, string, string, string, any) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testGroupMessageRuntime) StartRealtimeFanoutSubscriber(context.Context, string, func(*RealtimeFanoutEnvelope)) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *testGroupMessageRuntime) CacheObject(types.TenantID, string, string, any) error {
|
func (r *testGroupMessageRuntime) CacheObject(types.TenantID, string, string, any) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ type HTTPInfrastructurePersistenceInterface interface {
|
|||||||
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
|
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
|
||||||
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
|
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
|
||||||
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
|
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
|
||||||
CreateEvent(types.TenantID, *types.BusinessEvent) error
|
|
||||||
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
|
|
||||||
MarkEventDelivered(types.TenantID, string, time.Time) error
|
|
||||||
BeginKafkaConsume(types.TenantID, *types.KafkaConsumeRecord, time.Time) (*types.KafkaConsumeRecord, bool, error)
|
BeginKafkaConsume(types.TenantID, *types.KafkaConsumeRecord, time.Time) (*types.KafkaConsumeRecord, bool, error)
|
||||||
MarkKafkaConsumeSucceeded(types.TenantID, string, string, time.Time) error
|
MarkKafkaConsumeSucceeded(types.TenantID, string, string, time.Time) error
|
||||||
MarkKafkaConsumeFailed(types.TenantID, string, string, string, string, time.Time) (*types.KafkaConsumeRecord, error)
|
MarkKafkaConsumeFailed(types.TenantID, string, string, string, string, time.Time) (*types.KafkaConsumeRecord, error)
|
||||||
@@ -64,30 +61,6 @@ func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record
|
|||||||
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
|
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (httpInfrastructureMapper) CreateEvent(tenantID types.TenantID, event *types.BusinessEvent) error {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantBusinessEventCreate(tenantID, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (httpInfrastructureMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.BusinessEvent, error) {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantBusinessEventListPending(tenantID, limit, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (httpInfrastructureMapper) BeginKafkaConsume(tenantID types.TenantID, record *types.KafkaConsumeRecord, deadline time.Time) (*types.KafkaConsumeRecord, bool, error) {
|
func (httpInfrastructureMapper) BeginKafkaConsume(tenantID types.TenantID, record *types.KafkaConsumeRecord, deadline time.Time) (*types.KafkaConsumeRecord, bool, error) {
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+20
-24
@@ -1620,6 +1620,7 @@ type GroupBusinessPersistenceInterface interface {
|
|||||||
CreateSettings(types.TenantID, *types.GroupSettings) (*types.GroupSettings, error)
|
CreateSettings(types.TenantID, *types.GroupSettings) (*types.GroupSettings, error)
|
||||||
GetSettings(types.TenantID, string) (*types.GroupSettings, error)
|
GetSettings(types.TenantID, string) (*types.GroupSettings, error)
|
||||||
UpdateSettings(types.TenantID, string, types.GroupSettingsPatch) (*types.GroupSettings, error)
|
UpdateSettings(types.TenantID, string, types.GroupSettingsPatch) (*types.GroupSettings, error)
|
||||||
|
Dissolve(types.TenantID, string, types.Uid, string, time.Time) (*types.GroupSettings, error)
|
||||||
UpsertMember(types.TenantID, *types.GroupMember) (*types.GroupMember, error)
|
UpsertMember(types.TenantID, *types.GroupMember) (*types.GroupMember, error)
|
||||||
GetMember(types.TenantID, string, types.Uid) (*types.GroupMember, error)
|
GetMember(types.TenantID, string, types.Uid) (*types.GroupMember, error)
|
||||||
ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error)
|
ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error)
|
||||||
@@ -1644,8 +1645,6 @@ type GroupBusinessPersistenceInterface interface {
|
|||||||
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
|
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
|
||||||
TransferOwner(types.TenantID, string, types.Uid, types.Uid, int, time.Time) error
|
TransferOwner(types.TenantID, string, types.Uid, types.Uid, int, time.Time) error
|
||||||
CanPost(types.TenantID, string, types.Uid, bool, time.Time) (bool, error)
|
CanPost(types.TenantID, string, types.Uid, bool, time.Time) (bool, error)
|
||||||
ListPendingEvents(types.TenantID, int, time.Time) ([]types.GroupEvent, error)
|
|
||||||
MarkEventDelivered(types.TenantID, string, time.Time) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type groupBusinessMapper struct{}
|
type groupBusinessMapper struct{}
|
||||||
@@ -1721,6 +1720,25 @@ func (groupBusinessMapper) UpdateSettings(tenantID types.TenantID, topic string,
|
|||||||
return settings, err
|
return settings, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (groupBusinessMapper) Dissolve(tenantID types.TenantID, topic string, actor types.Uid, reason string, now time.Time) (*types.GroupSettings, error) {
|
||||||
|
if strings.TrimSpace(topic) == "" || actor.IsZero() {
|
||||||
|
return nil, types.ErrMalformed
|
||||||
|
}
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settings, err := tenantAdp.TenantGroupDissolve(tenantID, strings.TrimSpace(topic), actor, reason, now)
|
||||||
|
if err == nil {
|
||||||
|
if settings != nil {
|
||||||
|
_ = CacheObject(tenantID, cacheNamespaceGroupSettings, strings.TrimSpace(topic), settings)
|
||||||
|
} else {
|
||||||
|
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupSettings, strings.TrimSpace(topic))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
func (groupBusinessMapper) UpsertMember(tenantID types.TenantID, member *types.GroupMember) (*types.GroupMember, error) {
|
func (groupBusinessMapper) UpsertMember(tenantID types.TenantID, member *types.GroupMember) (*types.GroupMember, error) {
|
||||||
if member == nil || member.Topic == "" || member.UserID.IsZero() {
|
if member == nil || member.Topic == "" || member.UserID.IsZero() {
|
||||||
return nil, types.ErrMalformed
|
return nil, types.ErrMalformed
|
||||||
@@ -2096,28 +2114,6 @@ func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid ty
|
|||||||
return tenantAdp.TenantGroupCanPost(tenantID, strings.TrimSpace(topic), uid, admin, now)
|
return tenantAdp.TenantGroupCanPost(tenantID, strings.TrimSpace(topic), uid, admin, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (groupBusinessMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.GroupEvent, error) {
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return tenantAdp.TenantGroupEventListPending(tenantID, limit, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (groupBusinessMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
|
|
||||||
if strings.TrimSpace(eventID) == "" || now.IsZero() {
|
|
||||||
return types.ErrMalformed
|
|
||||||
}
|
|
||||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tenantAdp.TenantGroupEventMarkDelivered(tenantID, strings.TrimSpace(eventID), now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func groupMemberCacheID(topic string, uid types.Uid) string {
|
func groupMemberCacheID(topic string, uid types.Uid) string {
|
||||||
return strings.TrimSpace(topic) + ":" + uid.String()
|
return strings.TrimSpace(topic) + ":" + uid.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ type GroupSettingsPatch struct {
|
|||||||
MemberUploadFiles *bool
|
MemberUploadFiles *bool
|
||||||
MemberPrivateChat *bool
|
MemberPrivateChat *bool
|
||||||
MemberInvite *bool
|
MemberInvite *bool
|
||||||
|
MaxMemberCount *int
|
||||||
|
MaxAdminCount *int
|
||||||
MemberCountDelta int
|
MemberCountDelta int
|
||||||
Version int
|
Version int
|
||||||
}
|
}
|
||||||
@@ -209,19 +211,3 @@ type GroupFile struct {
|
|||||||
UploaderRaw int64 `db:"uploader_id"`
|
UploaderRaw int64 `db:"uploader_id"`
|
||||||
DeletedByRaw *int64 `db:"deleted_by"`
|
DeletedByRaw *int64 `db:"deleted_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupEvent struct {
|
|
||||||
ID int64 `db:"id"`
|
|
||||||
EventID string `db:"event_id"`
|
|
||||||
Topic string `db:"topic"`
|
|
||||||
ActorUserID Uid `db:"-"`
|
|
||||||
EventType string `db:"event_type"`
|
|
||||||
Payload []byte `db:"payload"`
|
|
||||||
Status string `db:"status"`
|
|
||||||
Attempts int `db:"attempts"`
|
|
||||||
NextRetryAt *time.Time `db:"next_retry_at"`
|
|
||||||
DeliveredAt *time.Time `db:"delivered_at"`
|
|
||||||
CreatedAt time.Time `db:"created_at"`
|
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
|
||||||
ActorRaw int64 `db:"actor_user_id"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ const (
|
|||||||
HTTPIdempotencyProcessing = "processing"
|
HTTPIdempotencyProcessing = "processing"
|
||||||
HTTPIdempotencyCompleted = "completed"
|
HTTPIdempotencyCompleted = "completed"
|
||||||
|
|
||||||
BusinessEventPending = "pending"
|
|
||||||
BusinessEventDelivered = "delivered"
|
|
||||||
|
|
||||||
KafkaConsumeProcessing = "processing"
|
KafkaConsumeProcessing = "processing"
|
||||||
KafkaConsumeSucceeded = "succeeded"
|
KafkaConsumeSucceeded = "succeeded"
|
||||||
KafkaConsumeFailed = "failed"
|
KafkaConsumeFailed = "failed"
|
||||||
@@ -40,23 +37,6 @@ type HTTPIdempotencyRecord struct {
|
|||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusinessEvent is a durable event destined for an online user or topic.
|
|
||||||
type BusinessEvent struct {
|
|
||||||
ID int64
|
|
||||||
EventID string
|
|
||||||
TargetType string
|
|
||||||
Target string
|
|
||||||
ActorUserID Uid
|
|
||||||
EventType string
|
|
||||||
Payload []byte
|
|
||||||
Status string
|
|
||||||
Attempts int
|
|
||||||
NextRetryAt *time.Time
|
|
||||||
DeliveredAt *time.Time
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// KafkaConsumeRecord stores idempotent consumer state for one event and group.
|
// KafkaConsumeRecord stores idempotent consumer state for one event and group.
|
||||||
type KafkaConsumeRecord struct {
|
type KafkaConsumeRecord struct {
|
||||||
EventID string
|
EventID string
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"permanent_accounts": false,
|
"permanent_accounts": false,
|
||||||
"expvar": "/debug/vars",
|
"expvar": "/debug/vars",
|
||||||
"server_status": "-",
|
"server_status": "-",
|
||||||
|
"node_id": "",
|
||||||
"use_x_forwarded_for": true,
|
"use_x_forwarded_for": true,
|
||||||
"x_frame_options": "SAMEORIGIN",
|
"x_frame_options": "SAMEORIGIN",
|
||||||
"default_country_code": "CN",
|
"default_country_code": "CN",
|
||||||
|
|||||||
+101
-9
@@ -1177,11 +1177,7 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
|
|||||||
pluginMessage(t.tenantID, data.Data, plgActCreate)
|
pluginMessage(t.tenantID, data.Data, plgActCreate)
|
||||||
|
|
||||||
t.broadcastToSessions(data)
|
t.broadcastToSessions(data)
|
||||||
if t.cat == types.TopicCatGrp {
|
publishRealtimeTopicMessage(t.name, data)
|
||||||
if err := store.PublishGroupFanout(currentNodeID(), data); err != nil {
|
|
||||||
logs.Warn.Printf("topic[%s]: failed to publish redis group fanout: %v", t.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendPush will update unread message count and send push notification.
|
// sendPush will update unread message count and send push notification.
|
||||||
if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil {
|
if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil {
|
||||||
@@ -1260,10 +1256,7 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to DB at master topic.
|
// Save to DB at master topic.
|
||||||
var attachments []string
|
attachments := messageAttachmentURLs(msg)
|
||||||
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
|
|
||||||
attachments = msg.Extra.Attachments
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.saveAndBroadcastMessage(msg, asUid, msg.Pub.NoEcho, attachments, msg.Pub.Head, msg.Pub.Content); err != nil {
|
if err := t.saveAndBroadcastMessage(msg, asUid, msg.Pub.NoEcho, attachments, msg.Pub.Head, msg.Pub.Content); err != nil {
|
||||||
logs.Err.Printf("topic[%s]: failed to save messagge - %s", t.name, err)
|
logs.Err.Printf("topic[%s]: failed to save messagge - %s", t.name, err)
|
||||||
@@ -1275,6 +1268,105 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func messageAttachmentURLs(msg *ClientComMessage) []string {
|
||||||
|
if msg == nil || msg.Pub == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attachments := make([]string, 0)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
add := func(raw string) {
|
||||||
|
url, key := normalizeAttachmentURL(raw)
|
||||||
|
if url == "" || seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
attachments = append(attachments, url)
|
||||||
|
}
|
||||||
|
if msg.Extra != nil {
|
||||||
|
for _, attachment := range msg.Extra.Attachments {
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, attachment := range inferAttachmentURLsFromContent(msg.Pub.Content) {
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferAttachmentURLsFromContent(content any) []string {
|
||||||
|
var attachments []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
addFileID := func(raw string) {
|
||||||
|
url, key := normalizeAttachmentURL(raw)
|
||||||
|
if url != "" && !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
attachments = append(attachments, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(any, int)
|
||||||
|
walk = func(value any, depth int) {
|
||||||
|
if value == nil || depth <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if fileID, ok := typed["file_id"].(string); ok {
|
||||||
|
addFileID(fileID)
|
||||||
|
}
|
||||||
|
if ref, ok := typed["ref"].(string); ok {
|
||||||
|
addFileID(ref)
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, depth-1)
|
||||||
|
}
|
||||||
|
case types.KVMap:
|
||||||
|
walk(map[string]any(typed), depth)
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, depth-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(content, 8)
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAttachmentURL(raw string) (string, string) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if fid := parseAttachmentFileID(raw); !fid.IsZero() {
|
||||||
|
return "/v0/file/s/" + fid.String(), fid.String()
|
||||||
|
}
|
||||||
|
return raw, raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAttachmentFileID(raw string) types.Uid {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return types.ZeroUid
|
||||||
|
}
|
||||||
|
if fid := types.ParseUid(raw); !fid.IsZero() {
|
||||||
|
return fid
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(raw, "/v0/file/s/"); idx >= 0 {
|
||||||
|
tail := raw[idx+len("/v0/file/s/"):]
|
||||||
|
if cut := strings.IndexAny(tail, "?#/"); cut >= 0 {
|
||||||
|
tail = tail[:cut]
|
||||||
|
}
|
||||||
|
if dot := strings.IndexByte(tail, '.'); dot >= 0 {
|
||||||
|
tail = tail[:dot]
|
||||||
|
}
|
||||||
|
return types.ParseUid(tail)
|
||||||
|
}
|
||||||
|
if dot := strings.IndexByte(raw, '.'); dot >= 0 {
|
||||||
|
return types.ParseUid(raw[:dot])
|
||||||
|
}
|
||||||
|
return types.ZeroUid
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Topic) validateMessageReplacement(asUid types.Uid, head map[string]any, now time.Time) error {
|
func (t *Topic) validateMessageReplacement(asUid types.Uid, head map[string]any, now time.Time) error {
|
||||||
if head == nil || head["replace"] == nil {
|
if head == nil || head["replace"] == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3540,6 +3540,61 @@ func TestHandleBroadcastDataWithAttachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleBroadcastDataInfersAttachmentFromContentFileID(t *testing.T) {
|
||||||
|
numUsers := 2
|
||||||
|
helper := TopicTestHelper{}
|
||||||
|
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
|
||||||
|
defer helper.tearDown()
|
||||||
|
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), []string{"/v0/file/s/KJ0Qlrk4Gzw"}, gomock.Any()).
|
||||||
|
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||||
|
|
||||||
|
from := helper.uids[0].UserId()
|
||||||
|
msg := &ClientComMessage{
|
||||||
|
AsUser: from,
|
||||||
|
Original: from,
|
||||||
|
Pub: &MsgClientPub{
|
||||||
|
Topic: "p2p",
|
||||||
|
Content: map[string]any{
|
||||||
|
"file_id": "KJ0Qlrk4Gzw",
|
||||||
|
"mime": "audio/mpeg",
|
||||||
|
"size": 14143,
|
||||||
|
"duration_ms": 3719,
|
||||||
|
},
|
||||||
|
Head: map[string]any{
|
||||||
|
"x-im-type": "voice",
|
||||||
|
},
|
||||||
|
NoEcho: true,
|
||||||
|
},
|
||||||
|
sess: helper.sessions[0],
|
||||||
|
}
|
||||||
|
helper.topic.handleClientMsg(msg)
|
||||||
|
helper.finish()
|
||||||
|
|
||||||
|
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
|
||||||
|
t.Fatal(errorMsgs[0].Ctrl.Text)
|
||||||
|
}
|
||||||
|
if len(helper.results[1].messages) != 1 {
|
||||||
|
t.Fatalf("Uid2: expected 1 message, got %d", len(helper.results[1].messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageAttachmentURLsDeduplicatesExplicitAndContentFileID(t *testing.T) {
|
||||||
|
msg := &ClientComMessage{
|
||||||
|
Extra: &MsgClientExtra{Attachments: []string{"/v0/file/s/KJ0Qlrk4Gzw"}},
|
||||||
|
Pub: &MsgClientPub{
|
||||||
|
Content: map[string]any{
|
||||||
|
"file_id": "KJ0Qlrk4Gzw",
|
||||||
|
"mime": "audio/mpeg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments := messageAttachmentURLs(msg)
|
||||||
|
if len(attachments) != 1 || attachments[0] != "/v0/file/s/KJ0Qlrk4Gzw" {
|
||||||
|
t.Fatalf("unexpected attachments: %#v", attachments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) {
|
func TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) {
|
||||||
topicName := "grpTest"
|
topicName := "grpTest"
|
||||||
chanName := "chnTest"
|
chanName := "chnTest"
|
||||||
|
|||||||
+2
-1
@@ -298,7 +298,6 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatchIMBusinessEvents(s.tenantID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
|
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
|
||||||
@@ -826,6 +825,7 @@ func changeUserState(s *Session, uid types.Uid, user *types.User, msg *ClientCom
|
|||||||
if state != types.StateOK {
|
if state != types.StateOK {
|
||||||
// Terminate all sessions.
|
// Terminate all sessions.
|
||||||
globals.sessionStore.EvictUser(uid, "")
|
globals.sessionStore.EvictUser(uid, "")
|
||||||
|
publishRealtimeEvictUser(s.tenantID, uid, "user_state_changed", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = store.Users.UpdateState(s.tenantID, uid, state)
|
err = store.Users.UpdateState(s.tenantID, uid, state)
|
||||||
@@ -901,6 +901,7 @@ func replyDelUser(s *Session, msg *ClientComMessage) {
|
|||||||
|
|
||||||
// Terminate all sessions. Skip the current session so the requester gets a response.
|
// Terminate all sessions. Skip the current session so the requester gets a response.
|
||||||
globals.sessionStore.EvictUser(uid, s.sid)
|
globals.sessionStore.EvictUser(uid, s.sid)
|
||||||
|
publishRealtimeEvictUser(s.tenantID, uid, "user_deleted", s.sid)
|
||||||
// Remove user from cache and announce to cluster that the user is deleted.
|
// Remove user from cache and announce to cluster that the user is deleted.
|
||||||
usersRemoveUser(msg.TenantID, uid)
|
usersRemoveUser(msg.TenantID, uid)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user