Compare commits

...
34 Commits
Author SHA1 Message Date
richjohnrambo c8d9146604 fix: include username in me endpoint 2026-09-17 15:23:47 +08:00
richjohnrambo ed976c76ff docs: update im integration and schema notes 2026-09-17 14:59:28 +08:00
richjohnrambo 50f8581667 fix: resolve im backend issue list regressions 2026-09-17 14:59:16 +08:00
richjohnrambo 760de35e10 fix: allow username-only registration without invite 2026-09-17 13:45:57 +08:00
richjohnrambo 8279ae577f fix: decouple meeting note upload binding 2026-09-17 13:38:55 +08:00
richjohnrambo 734e042af6 feat: expose account fields in user profiles 2026-09-17 13:37:34 +08:00
richjohnrambo 816be46e03 fix: fan out unread presence to online sessions 2026-09-17 10:24:39 +08:00
richjohnrambo 7df3313c78 fix: shorten session heartbeat interval to 15s 2026-09-17 00:16:14 +08:00
richjohnrambo 0f8e3b82b7 feat: enforce tenant content moderation 2026-09-16 22:21:25 +08:00
richjohnrambo 0ae4af0c31 fix: fan out receipts and align tenant schema 2026-09-16 22:09:52 +08:00
richjohnrambo 6729f40665 fix: sync topic sequence after server data 2026-09-16 16:21:53 +08:00
richjohnrambo 7461216c03 feat: merge registration invite and verification input 2026-09-16 14:29:59 +08:00
richjohnrambo 370e9878ed fix: notify p2p message mutation on user channel 2026-09-16 13:49:33 +08:00
richjohnrambo fa95dd72af fix: 后台登录审计查询按字符串返回用户ID 2026-09-16 13:39:50 +08:00
richjohnrambo 1038d25abd fix: 后台消息查询按字符串返回用户ID 2026-09-16 13:24:02 +08:00
Austin a01cdae971 fix(realtime): evict fanout 增加时效校验,防止延迟重放误踢新登录
deliverRealtimeSessionControl 分发前校验 Ctrl.Timestamp,超过 10s 的 session-control(evict)消息直接丢弃并记 skip_stale 指标,避免 consumer 重启重放 PEL / stream 积压时旧消息的 skipSid 与新会话不符导致新登录被误踢。无时间戳消息放行以兼容旧发布方。

验证:TestRealtime 3/3、regression_kick_notification 14/14、regression_mobile_kick 21/21。
2026-09-16 13:22:47 +08:00
richjohnrambo c0ab94723c fix: 后台用户查询按字符串返回用户ID 2026-09-16 13:10:37 +08:00
Austin 846615db69 Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev 2026-09-16 10:23:33 +08:00
Austin 4280b535c2 feat(session): 实现同平台抢登互踢功能,优化会话踢下线逻辑
新增同平台会话踢下线能力,将iOS/Android/mobile归为同一移动分组,web仅匹配web;扩展会话存储接口支持按平台、带原因踢除会话,同步更新集群广播逻辑与各调用点,统一传递踢下线原因参数
2026-09-16 10:23:27 +08:00
richjohnrambo 68b196215f fix: keep upload urls unchanged 2026-09-16 09:56:15 +08:00
richjohnrambo 1de69fcc5b fix: force file urls to https 2026-09-16 09:52:55 +08:00
richjohnrambo 9626e8ee7c fix: invalidate group settings cache on membership changes 2026-09-16 01:03:40 +08:00
richjohnrambo 139681fadb feat: shard group messages by month 2026-09-15 23:53:51 +08:00
richjohnrambo b7c2536527 fix: persist group join system notices 2026-09-15 19:20:07 +08:00
Austin dbb0a06db0 fix: 修复P2P主题别名订阅的兼容性问题
1. 订阅重复检查新增Original与RcptTo匹配判断,避免别名订阅误判已订阅
2. 重构订阅get查询处理逻辑,提取为独立函数
3. 针对P2P别名订阅场景返回200响应而非304,帮助客户端完成别名到标准主题名的重命名,避免发布失败
4. 优化群摘要接口的权限校验和异常处理逻辑
2026-09-15 18:53:49 +08:00
richjohnrambo 563ae9ee5e fix: fanout p2p system notices to users 2026-09-15 17:58:55 +08:00
richjohnrambo e29b5d3589 test: cover blocked profile visibility 2026-09-15 16:02:36 +08:00
richjohnrambo 152e2402fa fix: allow members to query own group mute 2026-09-15 15:55:13 +08:00
richjohnrambo bc27a5c574 feat: add message mutation sync 2026-09-15 15:54:52 +08:00
Austin 55afd976c9 fix(server): 修复群组权限缓存与解散群问题
1. 新增group事件处理逻辑,实时刷新内存中的群组权限与状态
2. 加载topic时过滤已解散的群组,防止被旧成员恢复
3. 补充群成员离开事件的用户ID字段,完善事件处理
2026-09-15 15:18:14 +08:00
Austin 5dd0a42002 fix: harden message seq allocation against stale redis replicas
- use sentinel failover client for group message runtime when
  sentinel_master_name/sentinel_addrs are configured, so seq counter
  reads never hit a stale replica after failover
- re-seed missing redis seq counter from DB topic seqid instead of
  trusting the caller in-memory baseline
- on unique-key conflict during async persist, re-allocate seq from DB
  latest, bump redis counter and fix the idempotency record so resends
  with the same client mid return the final seq
- add uk(topic, seqid) unique keys for im_messages in tenant DDL
2026-09-15 12:41:38 +08:00
richjohnrambo 4f6a996922 feat: support batch message recall 2026-09-15 10:46:07 +08:00
richjohnrambo 9ec5da5b02 fix: persist login sessions for workbench auth 2026-09-15 10:23:47 +08:00
richjohnrambo ef8091d805 fix: reject friend request to missing user 2026-09-15 00:48:36 +08:00
84 changed files with 7149 additions and 618 deletions
+3
View File
@@ -8,6 +8,9 @@
# IDE workspace files.
.idea/
# Trae workspace: AI test scripts, temp files, docs.
/.trae/
# Local Go build output.
/server/server
/.codex-gocache/
+118 -6
View File
@@ -58,6 +58,11 @@ type audit struct {
usernameB string
phoneB string
passB string
tokenC string
userC string
usernameC string
phoneC string
passC string
groupTopic string
groupVer int
groupQR string
@@ -110,6 +115,7 @@ func (a *audit) run() {
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123"
a.usernameB, a.phoneB, a.passB = "b"+suffix, "156"+suffix, "AuditPass123"
a.usernameC, a.phoneC, a.passC = "c"+suffix, "159"+suffix, "AuditPass123"
a.testHTTP("1", "1.1", "企业码解析", "POST", "/v1/im/tenants/resolve", map[string]any{"tenant_code": a.tenantCode, "platform": "ios", "app_version": "1.0.0"}, "", true, []int{200}, func(r imResp) string {
return fmt.Sprintf("tenant=%v endpoints=%v", dig(r.Data, "tenant", "code"), dig(r.Data, "endpoints", "api"))
@@ -143,7 +149,6 @@ func (a *audit) run() {
a.testHTTP("1", "1.8-submit", "实名提交", "POST", "/v1/im/security/realname/submissions", map[string]any{"name": "张三", "id_type": "cn_id", "id_number": "110101199001011234"}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("1", "1.11", "设置独立密码", "POST", "/v1/im/settings/independent-password", map[string]any{"password": "1234", "purpose": "youth_mode", "action_token": a.actionTok}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("1", "1.12", "应用版本查询", "GET", "/v1/im/app/releases/latest?platform=ios&current_version=1.0.0&channel=stable", nil, "", false, []int{200}, nil)
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("2", "2.1", "Bootstrap", "GET", "/v1/im/bootstrap", nil, a.tokenA, false, []int{200}, func(r imResp) string {
return fmt.Sprintf("entries=%v limits=%v", r.Data["entries"] != nil, r.Data["limits"] != nil)
@@ -174,7 +179,7 @@ func (a *audit) run() {
a.testHTTP("4", "4.forward", "消息逐条转发", "POST", "/v1/im/messages/forward", map[string]any{"mode": "separate", "sources": []map[string]any{{"topic": a.p2pTopic, "seq": 1}}, "target_topics": []string{"slf"}}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.bundle", "合并转发详情", "GET", "/v1/im/forward-bundles/fbundle_missing?cursor=", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.recall", "消息撤回不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/recall", map[string]any{"version": 1, "reason": "audit"}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 403, 404, 409}, nil)
a.testHTTP("4", "4.receipts", "消息回执查询", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/receipts?cursor=", nil, a.tokenA, false, []int{200, 400, 404, 403}, nil)
a.testHTTP("5", "5.friends", "好友列表", "GET", "/v1/im/friends?query=&initial=&cursor=", nil, a.tokenA, false, []int{200}, nil)
@@ -218,10 +223,58 @@ func (a *audit) run() {
a.testHTTP("6", "6.join-requests", "入群申请列表", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.summary", "群概要", "GET", groupPath+"/summary", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.ann-latest", "群公告最新", "GET", groupPath+"/announcements/latest", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.governance-patch", "更新群治理配置", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
announcementID := ""
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200}, func(r imResp) string {
announcementID = asString(r.Data["announcement_id"])
return "announcement_id=" + announcementID
})
if announcementID != "" {
a.testHTTP("6", "6.ann-detail", "群公告详情", "GET", groupPath+"/announcements/"+url.PathEscape(announcementID), nil, a.tokenB, false, []int{200}, nil)
a.testHTTP("6", "6.ann-read", "群公告已读", "POST", groupPath+"/announcements/"+url.PathEscape(announcementID)+"/read", map[string]any{}, a.tokenB, true, []int{200}, nil)
}
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.governance-member-forbidden", "普通成员更新群治理被拒绝", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "version": a.groupVer}, a.tokenB, true, []int{403}, nil)
a.testHTTP("6", "6.governance-patch", "更新群治理配置成功", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "member_upload_files": true, "version": a.groupVer}, a.tokenA, true, []int{200}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.role-limits", "管理员数量限制", "GET", groupPath+"/role-limits", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.role-limits-member-forbidden", "普通成员读取管理员限制被拒绝", "GET", groupPath+"/role-limits", nil, a.tokenB, false, []int{403}, nil)
a.testHTTP("6", "6.mute-member-forbidden", "普通成员禁言他人被拒绝", "POST", groupPath+"/mutes/batch", map[string]any{"user_ids": []string{a.userA}, "reason": "越权测试"}, a.tokenB, true, []int{403}, nil)
a.testHTTP("6", "6.mute-member", "群主禁言成员", "POST", groupPath+"/mutes/batch", map[string]any{"user_ids": []string{a.userB}, "reason": "联调禁言"}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.mute-self-view", "成员查看自身禁言状态", "GET", groupPath+"/mutes?cursor=", nil, a.tokenB, false, []int{200}, func(r imResp) string {
return fmt.Sprintf("items=%v", r.Data["items"])
})
a.testHTTP("6", "6.unmute-member", "群主解除成员禁言", "DELETE", groupPath+"/mutes/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.admin-add", "群主设置管理员", "POST", groupPath+"/admins/batch", map[string]any{"user_ids": []string{a.userB}, "version": a.groupVer}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.admin-governance", "管理员更新群治理", "PATCH", groupPath+"/governance", map[string]any{"member_private_chat": true, "version": a.groupVer}, a.tokenB, true, []int{200, 409}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.admin-remove", "群主取消管理员", "DELETE", groupPath+"/admins/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
a.registerThird("6", "6.join-user", a.usernameC, a.phoneC, a.passC)
joinRequestID, joinRequestVersion := "", 0
a.testHTTP("6", "6.join-create", "非成员提交入群申请", "POST", groupPath+"/join-requests", map[string]any{"message": "申请加入联调群", "source": "manual"}, a.tokenC, true, []int{200}, func(r imResp) string {
joinRequestID = asString(r.Data["request_id"])
joinRequestVersion = asInt(r.Data["version"])
return fmt.Sprintf("request_id=%s version=%d", joinRequestID, joinRequestVersion)
})
a.testHTTP("6", "6.join-list-member-forbidden", "普通成员读取入群申请被拒绝", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenB, false, []int{403}, nil)
a.testHTTP("6", "6.join-list-owner", "群主读取待审批申请", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200}, nil)
if joinRequestID != "" {
a.testHTTP("6", "6.join-accept", "群主通过入群申请", "POST", groupPath+"/join-requests/"+url.PathEscape(joinRequestID)+"/accept", map[string]any{"version": joinRequestVersion}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.join-detail", "审批后新成员读取群详情", "GET", groupPath, nil, a.tokenC, false, []int{200}, nil)
a.testHTTP("6", "6.join-leave", "新成员主动退群", "POST", groupPath+"/leave", map[string]any{}, a.tokenC, true, []int{200}, nil)
}
a.testHTTP("6", "6.admins", "管理员列表", "GET", groupPath+"/members?role=admin&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.mutes", "禁言列表", "GET", groupPath+"/mutes?cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil)
@@ -310,6 +363,18 @@ func (a *audit) run() {
return "category=" + a.feedbackCat
})
a.testHTTP("11", "11.feedback", "提交反馈", "POST", "/v1/im/feedback", map[string]any{"category": a.feedbackCat, "content": "自动化联调反馈", "contact": "", "attachment_file_ids": []string{}, "client_context": map[string]any{"source": "imtestaudit"}, "include_diagnostics": true}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.owner-version", "转让前读取群版本", "GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/governance", nil, a.tokenA, false, []int{200}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
transferToken := a.securityActionToken("6", "6.owner-transfer", "group_owner_transfer", a.tokenA)
a.testHTTPHeaders("6", "6.owner-transfer-action", "群主转让", "POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/owner-transfer", map[string]any{"target_user_id": a.userB, "version": a.groupVer}, a.tokenA, true, []int{200}, map[string]string{"X-Action-Token": transferToken}, nil)
dissolveToken := a.securityActionToken("6", "6.dissolve", "group_dissolve", a.tokenB)
a.testHTTPHeaders("6", "6.dissolve-action", "新群主解散群", "DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic), map[string]any{"reason": "自动化验收完成"}, a.tokenB, true, []int{200}, map[string]string{"X-Action-Token": dissolveToken}, nil)
a.testHTTP("6", "6.dissolved-detail", "解散后群详情返回 Gone", "GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic), nil, a.tokenA, false, []int{410}, nil)
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
}
func (a *audit) register(chapter, id, username, phone, password string, primary bool) {
@@ -337,6 +402,27 @@ func (a *audit) register(chapter, id, username, phone, password string, primary
}
}
func (a *audit) registerThird(chapter, id, username, phone, password string) {
var challenge, verification string
a.testHTTP(chapter, id+"-code", "入群用户注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) string {
challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge)
})
if challenge != "" {
a.testHTTP(chapter, id+"-verify", "入群用户验证码校验 "+username, "POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(r imResp) string {
verification = asString(r.Data["verification_token"])
return "verification_token=" + mask(verification)
})
}
token, userID, note, err := a.wsRegister(username, phone, password, challenge, verification)
res := result{Chapter: chapter, ID: id + "-ws-acc", Name: "入群用户 WebSocket 注册 " + username, Method: "WS", Path: "/v0/channels", Pass: err == nil, Note: note}
if err != nil {
res.Reason = err.Error()
}
a.results = append(a.results, res)
a.tokenC, a.userC = token, userID
}
func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) {
u, _ := url.Parse(a.wsURL)
q := u.Query()
@@ -408,7 +494,11 @@ func waitCtrl(conn *websocket.Conn, id string) (map[string]any, error) {
}
func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token string, idem bool, want []int, note func(imResp) string) {
status, parsed, raw, err := a.call(method, path, body, token, idem)
a.testHTTPHeaders(chapter, id, name, method, path, body, token, idem, want, nil, note)
}
func (a *audit) testHTTPHeaders(chapter, id, name, method, path string, body any, token string, idem bool, want []int, headers map[string]string, note func(imResp) string) {
status, parsed, raw, err := a.callHeaders(method, path, body, token, idem, headers)
r := result{Chapter: chapter, ID: id, Name: name, Method: method, Path: path, Status: status, Code: parsed.Code, Reason: parsed.Reason, Body: compact(raw)}
if err != nil {
r.Reason = err.Error()
@@ -427,6 +517,10 @@ func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token
}
func (a *audit) call(method, path string, body any, token string, idem bool) (int, imResp, string, error) {
return a.callHeaders(method, path, body, token, idem, nil)
}
func (a *audit) callHeaders(method, path string, body any, token string, idem bool, headers map[string]string) (int, imResp, string, error) {
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
@@ -458,6 +552,9 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
if idem {
req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano()))
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := a.client.Do(req)
if err != nil {
return 0, imResp{}, "", err
@@ -469,6 +566,21 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
return resp.StatusCode, parsed, string(b), nil
}
func (a *audit) securityActionToken(chapter, id, scene, token string) string {
challengeID, actionToken := "", ""
a.testHTTP(chapter, id+"-challenge", scene+" 二次验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": scene, "channel": "sms"}, token, true, []int{200}, func(r imResp) string {
challengeID = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challengeID)
})
if challengeID != "" {
a.testHTTP(chapter, id+"-verify", scene+" 二次验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challengeID)+"/verify", map[string]any{"code": "123456"}, token, true, []int{200}, func(r imResp) string {
actionToken = asString(r.Data["action_token"])
return "action_token=" + mask(actionToken)
})
}
return actionToken
}
func (a *audit) report() string {
var b strings.Builder
now := time.Now().Format("2006-01-02 15:04:05 -0700")
+169 -2
View File
@@ -55,6 +55,8 @@ type audit struct {
p2pTopic string
bobTopic string
groupTopic string
groupSeq int
groupFile string
fileURL string
messageID string
}
@@ -96,7 +98,7 @@ func main() {
}
func (a *audit) run() {
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
suffix := fmt.Sprintf("%08d", (time.Now().UnixNano()+int64(os.Getpid())*100003)%100000000)
nameSuffix := suffix[len(suffix)-6:]
a.usernameA, a.phoneA, a.passA = "wsa"+nameSuffix, "157"+suffix, "AuditPass123"
a.usernameB, a.phoneB, a.passB = "wsb"+nameSuffix, "158"+suffix, "AuditPass123"
@@ -330,9 +332,26 @@ func (a *audit) run() {
return "topic=" + topic
})
a.step("WS-21", "A/B 订阅群 Topic", "WS", a.groupTopic, func() string {
if alice == nil || bob == nil || a.groupTopic == "" {
if a.groupTopic == "" {
return "FAIL: websocket client or group missing"
}
// Reconnect before the group-specific checks. A read timeout permanently
// poisons a gorilla/websocket connection and must not cascade into this section.
if alice != nil && alice.conn != nil {
_ = alice.conn.Close()
}
if bob != nil && bob.conn != nil {
_ = bob.conn.Close()
}
var err error
alice, err = a.openLoggedIn("A-group", a.userA, a.tokenA)
if err != nil {
return "FAIL: A reconnect: " + err.Error()
}
bob, err = a.openLoggedIn("B-group", a.userB, a.tokenB)
if err != nil {
return "FAIL: B reconnect: " + err.Error()
}
if err := alice.sub(a.groupTopic); err != nil {
return "FAIL: A sub group: " + err.Error()
}
@@ -350,6 +369,112 @@ func (a *audit) run() {
if err != nil {
return "FAIL: " + err.Error()
}
a.groupSeq = seq
return fmt.Sprintf("seq=%d topic=%s", seq, topic)
})
a.step("WS-22E", "群消息 client_mid 幂等去重", "WS", a.groupTopic, func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix}
first, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix)
if err != nil {
return "FAIL: first pub: " + err.Error()
}
second, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix)
if err != nil {
return "FAIL: duplicate pub: " + err.Error()
}
if first <= 0 || second != first {
return fmt.Sprintf("FAIL: first_seq=%d duplicate_seq=%d", first, second)
}
return fmt.Sprintf("first_seq=%d duplicate_seq=%d", first, second)
})
a.step("WS-22F", "群消息上下文查询", "HTTP", "/v1/im/topics/{topic}/messages/{seq}/context", func() string {
time.Sleep(500 * time.Millisecond)
status, r, err := a.call("GET", fmt.Sprintf("/v1/im/topics/%s/messages/%d/context?before=5&after=5", url.PathEscape(a.groupTopic), a.groupSeq), nil, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
anchor, count := asInt(r.Data["anchor_seq"]), len(asSlice(r.Data["items"]))
if anchor != a.groupSeq || count == 0 {
return fmt.Sprintf("FAIL: anchor_seq=%d items=%d", anchor, count)
}
return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count)
})
a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string {
status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("WS 群消息")+"&type=text", nil, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
count := len(asSlice(r.Data["items"]))
if count == 0 {
return "FAIL: items=0"
}
return fmt.Sprintf("items=%d", count)
})
a.step("WS-22H", "群消息发送者撤回", "HTTP", "/v1/im/topics/{topic}/messages/{seq}/recall", func() string {
status, r, err := a.call("POST", fmt.Sprintf("/v1/im/topics/%s/messages/%d/recall", url.PathEscape(a.groupTopic), a.groupSeq), map[string]any{"version": 1, "reason": "WS 群消息撤回联调"}, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
return fmt.Sprintf("seq=%d status=%s version=%d", asInt(r.Data["seq"]), asString(r.Data["status"]), asInt(r.Data["version"]))
})
a.step("WS-22A", "HTTP 禁言群成员 B", "HTTP", "/v1/im/groups/{topic}/mutes/batch", func() string {
status, r, err := a.call("POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/mutes/batch", map[string]any{
"user_ids": []string{a.userB}, "reason": "WS 禁言策略联调",
}, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
return "member B muted"
})
a.step("WS-22B", "被禁言成员 B pub 被拒绝", "WS", a.groupTopic, func() string {
if bob == nil {
return "FAIL: websocket client missing"
}
id := "pub-muted-" + suffix
code, text, err := bob.sendCtrl(id, map[string]any{"pub": map[string]any{
"id": id, "topic": a.groupTopic, "head": map[string]any{"mime": "text/plain"}, "content": "本消息不应发出",
}}, 15*time.Second)
if err != nil {
return "FAIL: " + err.Error()
}
if code != http.StatusForbidden {
return fmt.Sprintf("FAIL: unexpected code=%d text=%s", code, text)
}
return fmt.Sprintf("code=%d text=%s", code, text)
})
a.step("WS-22C", "HTTP 解除群成员 B 禁言", "HTTP", "/v1/im/groups/{topic}/mutes/{user_id}", func() string {
status, r, err := a.call("DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/mutes/"+url.PathEscape(a.userB), nil, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
return "member B unmuted"
})
a.step("WS-22D", "解除禁言后 B pub,A 收到 data", "WS", a.groupTopic, func() string {
if alice == nil || bob == nil {
return "FAIL: websocket client missing"
}
seq, topic, err := bob.pubWithHeadAndPeer(alice, a.groupTopic, map[string]any{"mime": "text/plain", "x-im-client-mid": "unmute-" + suffix}, "解除禁言消息 "+suffix)
if err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("seq=%d topic=%s", seq, topic)
})
a.step("WS-23", "get 群 desc/sub/data", "WS", a.groupTopic, func() string {
@@ -369,6 +494,43 @@ func (a *audit) run() {
a.fileURL = fileURL
return "url=" + mask(fileURL)
})
a.step("WS-24A", "群文件关联 OSS 上传文件", "HTTP", "/v1/im/groups/{topic}/files", func() string {
status, r, err := a.call("POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files", map[string]any{
"file_id": a.fileURL, "file_name": "imwsaudit.txt", "mime_type": "text/plain", "file_type": "document",
}, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
a.groupFile = asString(r.Data["group_file_id"])
if a.groupFile == "" {
return "FAIL: missing group_file_id"
}
return "group_file_id=" + a.groupFile
})
a.step("WS-24B", "群文件列表可查询", "HTTP", "/v1/im/groups/{topic}/files", func() string {
status, r, err := a.call("GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files?query=imwsaudit&type=document", nil, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
count := len(asSlice(r.Data["items"]))
if status != http.StatusOK || count == 0 {
return fmt.Sprintf("FAIL: status=%d reason=%s items=%d", status, r.Reason, count)
}
return fmt.Sprintf("items=%d", count)
})
a.step("WS-24C", "群主删除群文件关联", "HTTP", "/v1/im/groups/{topic}/files/{group_file_id}", func() string {
status, r, err := a.call("DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files/"+url.PathEscape(a.groupFile), nil, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK || asString(r.Data["status"]) != "deleted" {
return fmt.Sprintf("FAIL: status=%d reason=%s file_status=%s", status, r.Reason, asString(r.Data["status"]))
}
return "status=deleted"
})
a.step("WS-25", "WS 文件消息下发", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil || a.fileURL == "" {
return "FAIL: websocket client or file missing"
@@ -1028,6 +1190,11 @@ func asInt(v any) int {
}
}
func asSlice(v any) []any {
items, _ := v.([]any)
return items
}
func mask(s string) string {
if len(s) <= 8 {
return s
@@ -475,6 +475,8 @@ im:{tenant_id}:...
当前代码将 Redis 配置挂在 `store_config.redis` 下;不配置则不启用 Redis 群消息发号和唯一 key,群消息仍兼容旧的本机 `lastID + 1` 发号。
群消息物理表按自然月拆分时,落库路由、跨月查询、幂等和迁移方案见:[IM-群消息按月分表改造方案](./IM-群消息按月分表改造方案.md)。
配置示例:
```json
+322 -55
View File
@@ -240,7 +240,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
##### 0.5.1.1 WebSocket 心跳与在线状态
当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 30 秒发送一次 WebSocket Ping 帧(`pingPeriod=30s`),并期望客户端在 45 秒读超时窗口内返回 Pong(`idleSessionTimeout=45s`);如果没有收到 Pong,或底层读写失败,服务端会关闭当前 Session。
当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 15 秒发送一次 WebSocket Ping 帧(`pingPeriod=15s`),并期望客户端在 45 秒读超时窗口内返回 Pong(`idleSessionTimeout=45s`);如果没有收到 Pong,或底层读写失败,服务端会关闭当前 Session。
客户端要求:
@@ -322,7 +322,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` |
| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
@@ -451,10 +451,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 |
| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 |
| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 |
| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
| 拉黑/取消拉黑 | 操作人本人 | 拉黑:`{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}`;取消拉黑:`{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.unblocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
| 单聊消息变更脏标记 | P2P 双方在线会话 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"message.mutation","payload":{"topic":"p2p...","seq":12,"version":2,"event":"message.recalled"}}}` | 当前未订阅该 P2P 也要标记会话脏,并调用 `message-mutations` 做补偿同步 |
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 |
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
@@ -493,16 +494,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
客户端侧要求:
- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。
- 登录 IM 后将极光 alias 绑定为当前 `user_id` 的安全别名:`user_id.replace("-", ".").replace("_", "|")`,例如 `usrd6Ag5opa-ww` 绑定为 `usrd6Ag5opa.ww`;同一用户多设备允许绑定同一个 alias。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。
- 当前测试环境使用 `registration_id` 模式,服务端配置 `jpush_use_alias=0`,不依赖客户端 alias;alias 绑定可保留但不是投递依据。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。`device_id` 必须稳定,同一台设备不要每次启动变化。
- 切换账号、切换租户、清登录态后必须重新上报 Push Token;客户端本地的 `user_id/token/registration_id` 去重缓存必须按 `tenant_code + user_id + device_id + registration_id` 隔离,避免把设备继续绑在旧账号上。
- 若后续切回 alias 模式,登录 IM 后将极光 alias 绑定为当前 `user_id` 的安全别名:`user_id.replace("-", ".").replace("_", "|")`,例如 `usrd6Ag5opa-ww` 绑定为 `usrd6Ag5opa.ww`;同一用户多设备允许绑定同一个 alias。
- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。
服务端侧要求:
- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。
- 当前测试环境按 `registration_id` 投递:服务端从 `im_device_push_tokens` 按目标 `user_id + provider=jpush` 查询设备 token,并调用极光 `audience.registration_id`。
- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。
- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。
- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。
- iOS 测试环境固定使用 `options.apns_production=false`;客户端包也必须是开发 APNs 环境。TestFlight/App Store 生产包需要后端与客户端同时切到生产 APNs。
极光 Push extras 统一格式:
@@ -531,22 +536,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json
{
"platform": "all",
"platform": "ios",
"audience": {
"alias": ["usr_target"]
"registration_id": ["161a3797c95ee4a8bd3"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
@@ -574,25 +568,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json
{
"platform": "all",
"platform": "ios",
"audience": {
"alias": ["usr_callee"]
"registration_id": ["161a3797c95ee4a8bd3"]
},
"notification": {
"alert": "收到一通语音通话",
"android": {
"title": "语音通话",
"alert": "张三邀请你语音通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
},
"ios": {
"alert": {
"title": "语音通话",
@@ -863,19 +843,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/注册页面.png" alt="注册页面" width="360">
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。
- 旧版独立 `invite_code` 字段仅保留协议兼容,新客户端不得再展示独立邀请码输入框。
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。
- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。
- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。
- 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `tel credential`,同时写入邀请码推荐使用关系。
- 提交注册:`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
@@ -883,19 +864,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
用户名方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}}
```
手机号验证方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
仅手机号注册方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
@@ -1001,6 +982,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 邀请码 | `{"code":"AbCdE"}` | `{"verification_mode":"invite_code","invite_accepted":true}` |
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
@@ -1418,7 +1400,9 @@ 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}` |
| `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"}` |
| `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` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"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/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
| `GET /v1/im/topics/{topic}/message-mutations?since_version=&limit=` | 拉取撤回/编辑等原地消息变更;`since_version` 取本地已应用的会话级 `mutation_version` | `{"topic":"grp...","items":[{"seq":12,"version":2,"event":"message.recalled","operator":"usr...","mutated_at":"2026-08-20T09:00:00Z","mutation_version":3}],"next_cursor":"3","has_more":false,"mutation_version":3,"expired":false}` |
| `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/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":""}` |
@@ -1426,7 +1410,251 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` |
| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。批量撤回按成功项逐条产生 `message.recalled` 事件;失败项不会产生事件。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
#### 4.21.2 群消息撤回与批量撤回
群聊撤回使用 Topic 消息操作接口,`topic` 必须传真实群 Topic(`grp...`),不能传用户 ID、群 ID 昵称或本地会话别名。单条撤回和多选批量撤回都要求登录态,Header 至少包含 `Authorization: Bearer <token>`、`X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`。
单条撤回:
```http
POST /v1/im/topics/{topic}/messages/{seq}/recall
```
Path 参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
| `seq` | Int64 | 是 | 要撤回的消息序号。 |
Body:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `version` | Int | 否 | 期望消息版本。不传时后端使用当前消息版本;传旧版本可能返回版本冲突。 |
| `reason` | String | 否 | 撤回原因,当前主要用于客户端/审计扩展,可传空。 |
单条请求示例:
```json
{
"version": 1,
"reason": "mistake"
}
```
单条成功响应 `data`:
```json
{
"topic": "grp6PIoZgdpc2g",
"seq": 102,
"status": "recalled",
"version": 2,
"updated_at": "2026-09-15T04:30:00Z",
"recalled_at": "2026-09-15T04:30:00Z",
"recalled_by": "usr_actor"
}
```
多选批量撤回:
```http
POST /v1/im/topics/{topic}/messages/recall
```
Path 参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
Body:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `items` | Array | 是 | 要撤回的消息列表,长度 1~50。 |
| `items[].seq` | Int64 | 是 | 要撤回的消息序号。 |
| `items[].version` | Int | 否 | 该消息期望版本。不传时后端使用当前消息版本。 |
| `items[].reason` | String | 否 | 单条撤回原因;可不传。 |
| `reason` | String | 否 | 批量默认撤回原因;单条 `items[].reason` 可覆盖。 |
批量请求示例:
```json
{
"items": [
{
"seq": 102,
"version": 1
},
{
"seq": 103,
"version": 1
}
],
"reason": "mistake"
}
```
批量响应始终按条目返回处理结果。一条失败不影响其他条目;成功条目会包含最新 `version`,失败条目会包含该条失败原因。
```json
{
"topic": "grp6PIoZgdpc2g",
"items": [
{
"topic": "grp6PIoZgdpc2g",
"seq": 102,
"status": "recalled",
"version": 2,
"updated_at": "2026-09-15T04:30:00Z",
"recalled_at": "2026-09-15T04:30:00Z",
"recalled_by": "usr_actor"
},
{
"seq": 103,
"status": "failed",
"http": 409,
"code": 30006,
"reason": "VERSION_CONFLICT",
"message": "message version conflict"
}
]
}
```
常见失败项:
| `http` | `code` | `reason` | 说明 |
| --- | --- | --- | --- |
| `400` | `30000` | `MALFORMED_REQUEST` | `seq` 非法、`items` 为空或超过 50 条。 |
| `403` | `30005` | `PERMISSION_DENIED` | 当前用户无权撤回该消息,或消息已超过撤回窗口。超过窗口时 `message` 为 `message recall window expired`。 |
| `404` | `30004` | `MALFORMED_REQUEST` | 消息不存在。 |
| `409` | `30006` | `VERSION_CONFLICT` | 请求版本与服务端当前消息版本不一致,或消息已撤回/删除。 |
权限与时限:
- 发送人可撤回自己的消息。
- 群主/管理员可撤回群成员消息。
- 当前后端撤回窗口为 120 秒;超过后返回 `message recall window expired`。
- 被撤回消息服务端会清空普通正文,历史拉取应展示撤回占位,不继续展示旧内容。
实时事件:
撤回成功后,Topic 在线成员会收到 `message.recalled`。批量撤回按成功项逐条发送事件,失败项不会发送。
```json
{
"info": {
"topic": "grp6PIoZgdpc2g",
"from": "usr_actor",
"what": "im",
"event": "message.recalled",
"payload": {
"topic": "grp6PIoZgdpc2g",
"seq": 102,
"version": 2
}
}
}
```
客户端处理规则:
- 多选撤回优先调用批量接口,不要并发打很多单条接口。
- 批量接口 HTTP `200` 只表示请求被服务端处理,仍需逐条读取 `items[].status`。
- `status="recalled"` 时,用 `topic + seq + version` 更新本地消息为撤回状态。
- `status="failed"` 时,只对该条提示失败原因,其他成功项正常生效。
- 收到实时事件后也按 `topic + seq + version` 刷新本地消息,避免只依赖发起方本地状态。
#### 4.21.3 消息变更同步水位
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;单聊中如果客户端在线但当前没有订阅该 P2P,服务端会额外向双方用户通道下发 `message.mutation` 脏标记事件;离线或错过实时事件时,需要靠会话级水位补偿。
单聊未订阅脏标记示例:
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "message.mutation",
"payload": {
"topic": "p2pJaOWxMvALjB4i1K_h_NZgQ",
"seq": 12,
"version": 2,
"event": "message.recalled"
}
}
}
```
收到 `message.mutation` 后不要直接改本地消息内容,先把 `payload.topic` 标记为脏,再调用:
```http
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
```
会话列表同步时,`me.meta.sub[].private` 会带:
```json
{
"mutation_version": 3
}
```
客户端按账号 + topic 保存本地已应用水位。若服务端 `mutation_version` 大于本地值,调用:
```http
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
```
响应 `data`:
```json
{
"topic": "grp6PIoZgdpc2g",
"items": [
{
"seq": 102,
"version": 2,
"event": "message.recalled",
"operator": "usr_actor",
"mutated_at": "2026-09-15T04:30:00Z",
"mutation_version": 3
}
],
"next_cursor": "3",
"has_more": false,
"mutation_version": 3,
"expired": false
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `seq` | 被变更消息的序号。 |
| `version` | 变更后的消息版本,用于同一 `seq` 的幂等与乱序保护。 |
| `event` | `message.recalled` / `message.edited`,与实时 `info.event` 保持一致。 |
| `operator` | 操作人用户 ID。 |
| `mutated_at` | 变更发生时间。 |
| `mutation_version` | 会话级最新水位。客户端成功应用返回项后推进本地水位。 |
| `next_cursor` | 当前实现等同最新 `mutation_version`,客户端原样保存即可。 |
| `expired` | 当前轻量实现固定为 `false`;后续若服务端裁剪变更记录,该字段会用于提示客户端降级重拉。 |
客户端处理规则:
- `mutation_version` 是会话级水位,不是单条消息的 `version`。
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
## 5. 通讯录、好友关系与黑名单
@@ -1498,7 +1726,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。个人资料页头部和单聊标题统一使用 `display_name`,需要展示对方原始昵称时再使用 `nickname`。
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1530,7 +1758,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。
- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。
- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `relationship.blocked`;解除拉黑推送 `relationship.unblocked`。事件 `topic` 为操作人自己的 `usr_actor`,`payload.user_id` 为操作人 ID;客户端收到后刷新黑名单、好友列表和会话 context。拉黑后服务端拦截对方消息和呼叫。
- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。
### 5.11 删除好友提示.png
@@ -1595,7 +1823,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
@@ -1736,6 +1964,45 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。
- 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。
### 6.13.1 群成员列表拉取与缓存策略
**状态:已按分页 + 服务端页缓存实现。**
客户端进入普通群聊会话时不要主动全量拉取群成员列表;群详情中的 `member_count`、当前用户权限和会话上下文足够渲染聊天页。只有进入群成员页、管理员候选页、禁言候选页、转让群主候选页或输入 `@` 搜索成员时,才调用 `GET /v1/im/groups/{topic}/members`。
请求参数:
| 参数 | 类型 | 用法 |
| --- | --- | --- |
| `topic` | Path string | 群 Topic,例如 `grpORCbFk4mAzk`。 |
| `query` | Query string,可选 | 成员搜索关键字。空或省略表示拉当前筛选条件下的成员页;有值时服务端按群昵称、用户昵称、备注展示名、用户名等可搜索字段匹配。`@` 人、候选人选择器必须优先传 `query`,不要先拉全量再本地筛。 |
| `cursor` | Query string,可选 | 分页游标。第一页省略或传空;下一页必须使用上一次响应的 `next_cursor`,客户端不得自行拼游标。 |
| `limit` | Query int,可选 | 每页数量,默认 `30`,最大 `100`。万人群场景建议普通列表 `30`,搜索/候选页 `30`,不要循环预拉完整群成员。 |
| `mentionable` | Query bool,可选 | `@` 成员选择器传 `true`,服务端只返回当前可被提及的成员,并在响应里返回 `permissions.can_mention_all`;普通成员列表省略或传 `false`。 |
| `role` | Query enum,可选 | 按现有角色筛选:`owner`、`admin`、`member`。例如管理员列表传 `role=admin`。 |
| `eligible_role` | Query enum,可选 | 候选角色视图:`admin` 表示添加管理员候选,`owner` 表示转让群主候选;响应项会带 `eligible`、`ineligible_reason`。 |
| `mute_eligible` | Query bool,可选 | 禁言候选选择器传 `true`;响应项会带 `eligible`、`ineligible_reason`。该参数用于响应阶段计算候选状态,不改变公共成员页缓存维度。 |
响应重点字段:`items[]` 返回 `user_id,display_name,group_nickname,avatar_file_id,role,mentionable,acs,eligible,ineligible_reason`;`next_cursor` 为空表示没有下一页;`permissions.can_mention_all` 只表示当前登录用户是否可以使用 `@所有人`。
典型调用:
| 场景 | 调用方式 | 说明 |
| --- | --- | --- |
| 进入普通群聊 | 不调用成员列表 | 只用群详情、会话上下文和 `member_count`。 |
| 群成员第一页 | `GET /v1/im/groups/{topic}/members?limit=30` | 打开成员列表时拉第一页。 |
| 群成员下一页 | `GET /v1/im/groups/{topic}/members?cursor={next_cursor}&limit=30` | 只使用服务端返回的 `next_cursor`。 |
| 成员搜索 | `GET /v1/im/groups/{topic}/members?query=张&limit=30` | `query` 变化时清空本地结果,从第一页重新拉。 |
| `@` 选择器 | `GET /v1/im/groups/{topic}/members?query=张&mentionable=true&limit=30` | 只展示 `mentionable=true` 的成员;是否展示 `@所有人` 看 `permissions.can_mention_all`。 |
| 管理员列表 | `GET /v1/im/groups/{topic}/members?role=admin&limit=30` | 群设置页展示管理员。 |
| 添加管理员候选 | `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=张&limit=30` | 用 `eligible=false` 和 `ineligible_reason` 禁用不可选项。 |
| 转让群主候选 | `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=张&limit=30` | 只允许选择符合转让条件的成员。 |
| 禁言候选 | `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=张&limit=30` | 管理员选择禁言对象时使用。 |
客户端本地缓存建议按 `tenant_code + my_user_id + topic + query + cursor + limit + role + eligible_role + mentionable + mute_eligible` 建 key,只缓存已加载页,并且只在当前页面生命周期内复用。收到 `group.member.joined`、`group.member.left`、`group.member.updated`、`group.settings.updated` 等群业务事件后,应清理该 `topic` 的本地成员列表、候选人和 `@` 搜索缓存;只发生用户资料/备注变化时,可刷新当前可见页或对应用户资料。
服务端以 `topic + role + eligible_role + query + cursor + limit + mentionable` 作为 Redis 成员页缓存维度,同一页短时间重复请求会命中页缓存。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人实时补齐。成员加入、退出、角色/昵称/状态变化时按 `topic` 清理相关页缓存。该接口不承载在线状态,在线/离线应走 presence 或会话状态逻辑。
### 6.14 群简介.png
<img src="images/群简介.png" alt="群简介" width="360">
@@ -1761,7 +2028,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
@@ -1908,7 +2175,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` |
@@ -2645,7 +2912,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`;`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
@@ -2745,7 +3012,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。
- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,friend_request_verification,group_invite_verification,version` 和各字段 `editable`;`friend_request_verification=true` 表示别人加我为好友时需要我确认,`false` 时符合来源规则的好友申请自动通过;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。
- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。
- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。
@@ -2851,10 +3118,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
+1
View File
@@ -712,6 +712,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
friend_request_verification TINYINT NOT NULL DEFAULT 1,
group_invite_verification TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -1,5 +1,16 @@
# IM 注册、邀请码与认证数据模型改造需求文档
## 0. 统一验证码输入变更(2026-09-16)
本节为最新规则,覆盖本文后续章节中“独立邀请码输入框”“短信开启时邀请码不能替代验证码”等旧描述:
1. 注册页面取消独立邀请码输入框,邀请码与短信验证码共用同一个输入框。
2. 5 位大小写英文字母按邀请码处理,6 位数字按短信验证码处理,其他格式拒绝。
3. 有效邀请码可以作为注册放行凭证;注册成功后写入 `im_registration_invite_uses`,建立邀请码与新用户的推荐使用关系。
4. 短信验证码注册会建立已完成的 `tel credential`;邀请码注册只保存手机号,不把手机号标记为短信已验证。
5. 新客户端不再提交独立 `invite_code`,而是在 `{acc}.cred[].resp` 中提交用户在统一输入框填写的 5 位邀请码。旧 `invite_code` 字段仅保留向后兼容。
6. `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 输入邀请码时返回 `{"verification_mode":"invite_code","invite_accepted":true}`;输入短信验证码时继续返回一次性 `verification_token`。
> 版本:V1.0 评审稿
> 编写日期:2026-08-18
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
+10
View File
@@ -423,6 +423,16 @@ stateDiagram-v2
| 转让群主 | 二次确认;同一事务更新 owner、双方角色、Topic Owner/ACL、审计和 Outbox |
| 解散群聊 | 标记群状态、踢出成员、停用二维码和审批入口 |
### 10.3 群成员列表读取与缓存
群成员列表按需分页读取,不作为进入会话的必拉数据。普通聊天页只依赖群详情、当前用户权限和 `member_count`;成员页、候选人选择器和 `@` 搜索才调用成员列表接口。
服务端成员列表查询接口为 `GET /v1/im/groups/{topic}/members`。参数含义:`query` 用于搜索成员,空值表示读取当前筛选条件下的成员页;`cursor` 必须来自上一页 `next_cursor`;`limit` 默认 `30`,最大 `100`;`mentionable=true` 用于 `@` 选择器,只返回可被提及成员;`role=owner|admin|member` 用于按现有角色筛选;`eligible_role=admin|owner` 用于添加管理员和转让群主候选视图;`mute_eligible=true` 用于禁言候选视图。候选视图的 `eligible/ineligible_reason` 在 HTTP 响应阶段按当前登录人的权限和目标成员状态计算。
成员页按 `im_group_members.id` 游标分页,常规列表走 `(topic, member_status, id)` 索引,角色筛选走 `(topic, role, member_status, id)` 索引,搜索和候选列表必须复用分页查询,不得提供一次返回全群成员的接口。万人群场景中,客户端打开群聊不拉成员列表;打开成员页只拉第一页;搜索、`@`、管理员候选、禁言候选和转让群主候选都带 `query` 缩小结果集。
成员页缓存使用 Redis,缓存维度为 `topic + role + eligible_role + query + cursor + limit + mentionable`,缓存内容为成员页结果和 `next_cursor`。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人补齐。成员加入、退出、角色变更、群昵称变更、成员状态变化、资料投影刷新等写操作必须调用 `invalidateGroupMemberPages(topic)` 清理该群所有成员页缓存。
## 11. 收藏、转发与搜索设计
### 11.1 收藏
@@ -0,0 +1,555 @@
# IM 群消息按月分表改造方案
## 1. 背景与目标
当前每个租户库使用单表 `im_group_messages` 保存全部群消息。随着群数量、消息量和保留时间增长,单表会出现索引膨胀、查询抖动、DDL 风险升高和归档困难等问题。
本方案将群消息正文按自然月拆分为物理表:
```text
im_group_messages_202609
im_group_messages_202610
im_group_messages_202611
```
目标:
1. 每个租户库独立分表,消息表不增加 `tenant_id`。
2. Topic 的 `seqid` 继续由 Redis 原子递增,跨月不重置。
3. Kafka 异步落库稳定路由到首次确定的月表。
4. 客户端继续按 `topic + seq` 拉取,不感知月份。
5. 支持跨月分页、补拉、指定消息、搜索、撤回、编辑和删除。
6. 旧单表平滑迁移,不停服、不丢失、不重复。
7. Redis 保留 24 小时热消息,MySQL 月表仍是最终数据源。
## 2. 核心原则
### 2.1 分表粒度
- 一个租户库每月一张群消息表,不按单个群建表。
- 表名固定为 `im_group_messages_YYYYMM`。
- 月份统一使用 UTC,但最终写入月份由 Redis 全局 `active_month` 决定,避免多节点在月初产生交叠。
- Redis Lua 一次性返回 `seqid + shard_month`;生产端把结果固化到 Kafka,重试、补偿不得重算。
### 2.2 Seq 规则
- `seqid` 在 Topic 内全生命周期单调递增,跨月不归零。
- Redis `topic:last_seq` 是唯一发号源。
- 月表只保存已分配的 Seq,不通过 `MAX(seqid)` 发号。
- 客户端游标继续使用 `before/since/seq`。
### 2.3 分层职责
| 层级 | 职责 |
| --- | --- |
| Redis | 原子 Seq、全局活动月份、长期 Topic 月段路由、短期发送幂等、24 小时热消息、在线状态、实时推送 |
| Kafka | 异步落库、削峰、重试、死信、消费幂等 |
| MySQL 月表 | 群消息正文最终存储 |
| MySQL 路由镜像 | 月表目录、Topic 月段路由持久化镜像;Redis 丢失时用于重建,不进入正常查询热路径 |
| `im_dellog` | 用户视角删除范围,每张月表查询时继续过滤 |
## 3. 表结构
### 3.1 月表模板
第一阶段保持当前字段兼容,降低 Store 层改造范围。
```sql
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
message_id BIGINT NOT NULL COMMENT 'Snowflake 全局消息 ID',
topic VARCHAR(64) NOT NULL COMMENT '群 Topic',
group_id BIGINT NOT NULL COMMENT '业务群 ID',
seqid BIGINT NOT NULL COMMENT 'Topic 内递增,跨月不重置',
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NULL,
msg_type VARCHAR(32) NOT NULL DEFAULT 'text',
status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON NULL,
head JSON NULL,
mentioned_user_ids JSON NULL,
mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT NULL,
root_seqid BIGINT NULL,
file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0,
has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass',
version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3) NULL,
recalled_by BIGINT NULL,
deleted_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (message_id),
UNIQUE KEY uk_topic_seq (topic, seqid),
UNIQUE KEY uk_topic_sender_client_mid (topic, from_user, client_msg_id),
KEY idx_group_seq (group_id, seqid),
KEY idx_topic_created (topic, created_at),
KEY idx_topic_from_seq (topic, from_user, seqid),
KEY idx_audit_created (audit_status, created_at),
KEY idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
说明:
1. `message_id` 使用 Snowflake 全局唯一 ID,作为月表物理主键和跨表引用 ID。
2. 聊天协议定位、补拉和已读水位仍统一使用 `topic + seqid`,不向客户端暴露物理主键。
3. 单表唯一索引不能覆盖跨月重试,跨月幂等由 Redis、消费日志和幂等表共同保证。
4. 文件二进制仍在 OSS,月表仅保存消息和文件元数据。
### 3.2 月表目录
```sql
CREATE TABLE IF NOT EXISTS im_group_message_shards (
shard_month CHAR(6) NOT NULL COMMENT 'UTC 月份 YYYYMM',
table_name VARCHAR(64) NOT NULL,
shard_start DATETIME(3) NOT NULL,
shard_end DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'prepared'
COMMENT 'prepared/writable/sealing/sealed/archived',
row_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NULL,
max_created_at DATETIME(3) NULL,
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 (shard_month),
UNIQUE KEY uk_table_name (table_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
表名只允许由服务端根据月份生成,并且必须存在于目录表,禁止使用请求参数直接拼接 SQL。
### 3.3 Redis Topic 月段路由
在线查询使用 Redis 路由,路由 Key 长期不过期:
```text
# 当前可写月份
im:{tenant_id}:groupmsg:active_month
STRING = 202610
# Topic 月份段,score 为该月首条 seq
im:{tenant_id}:groupmsg:route:{topic}
ZSET: 202609 => 1
202610 => 10087
# 月段完整范围,field 为 YYYYMM
im:{tenant_id}:groupmsg:route_meta:{topic}
HASH: 202609 => min_seq=1,max_seq=10086,count=10086
202610 => min_seq=10087,max_seq=12000,count=1914
```
路由规则:
1. `route` 和 `route_meta` 不设置 TTL,只在群物理删除或对应消息保留期结束后清理。
2. 精确 Seq 定位使用 `ZREVRANGEBYSCORE route seq -inf LIMIT 0 1`,找到最大 `min_seq <= seq` 的月份,再校验 Hash 中的 `max_seq`。
3. 倒序分页通过 `ZREVRANGE` 按月段倒序读取,正向补拉通过 `ZRANGE` 读取。
4. 路由 Key 使用 Redis Cluster hash tag,保证 Lua 和 Pipeline 访问同一槽位。
5. Redis 开启 AOF everysec、主从复制和持久化备份;同时保留 MySQL 镜像作为灾备重建来源。
活动月份和 Seq 必须在同一个 Lua 脚本中读取/分配,两个 Key 使用相同的租户 hash tag:
```lua
local shard_month = redis.call('GET', KEYS[1])
if not shard_month or shard_month < ARGV[1] then
redis.call('SET', KEYS[1], ARGV[1])
shard_month = ARGV[1]
end
local seq_id = redis.call('INCR', KEYS[2])
return {seq_id, shard_month}
```
其中 `KEYS[1]` 是租户活动月份,`KEYS[2]` 是 Topic Seq,`ARGV[1]` 来自 Redis `TIME` 转换后的 UTC 月份。业务节点不得根据本机时间计算月份;首次写入可初始化活动月份,之后 Lua 只允许月份单向推进,旧节点不能回拨。
### 3.4 MySQL 路由镜像
```sql
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
topic VARCHAR(64) NOT NULL,
shard_month CHAR(6) NOT NULL,
min_seq BIGINT NOT NULL,
max_seq BIGINT NOT NULL,
message_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NOT NULL,
max_created_at DATETIME(3) NOT NULL,
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 (topic, shard_month),
KEY idx_topic_max_seq (topic, max_seq),
KEY idx_month_topic (shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
该表不参与正常消息查询,只用于 Kafka 事务持久化、Redis 路由重建、历史迁移、数据核对和归档。Redis 与镜像不一致时,以月表实际数据和 MySQL 镜像修复 Redis。
路由一致性规则:
1. Kafka 消费事务同时提交消息和 MySQL 路由镜像,保证最终数据与镜像一致。
2. MySQL 提交成功后再更新 Redis `route/route_meta`;Redis 更新失败不回滚消息,进入路由修复重试队列。
3. 查询发现 Redis 路由 Key 不存在时,从 MySQL 镜像加载该 Topic 全部有效月段并回填 Redis,然后重试查询。
4. Shard Manager 启动时可按租户增量重建路由,并定时抽样核对 Redis、MySQL 镜像和月表实际 Seq 范围。
5. Redis 路由不设置 TTL;只有群被物理删除,或消息保留策略确认清除对应月份后,才同步删除相应月段。
### 3.5 跨月幂等表
```sql
CREATE TABLE IF NOT EXISTS im_group_message_dedup (
topic VARCHAR(64) NOT NULL,
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL,
shard_month CHAR(6) NOT NULL,
message_id BIGINT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
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 (topic, from_user, client_msg_id),
KEY idx_expires (expires_at),
KEY idx_topic_seq (topic, seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
- Redis `SET NX` 是入口快速幂等。
- Kafka 消费日志按 `event_id` 防止同一事件重复消费。
- 该表防止月末发送、月初重试时同一 `client_msg_id` 落入两张表。
- 建议保留 7 至 30 天后清理,不作为永久消息索引。
## 4. 建表和月切换
每个租户库运行统一 Shard Manager:
1. 每天巡检上月、当月和未来两个月。
2. 缺表时根据固定模板创建,成功后登记目录。
3. 使用 Redis 分布式锁和 Redis `TIME` 判断月界,确认新月表可写后再原子更新 `active_month`。
4. 当月设置为 `writable`,所有节点通过 Redis 读取同一个活动月份。
5. 上月进入 `sealing`,建议继续接受 7 天延迟 Kafka 消息。
6. 确认没有积压和死信后改为 `sealed`。
7. 建表失败立即告警,不切换 `active_month`,禁止在业务写入事务中临时执行 DDL。
月切换示例:
```text
2026-09-30 23:59:59 UTC seq=10086 -> im_group_messages_202609
2026-10-01 00:00:00 UTC seq=10087 -> im_group_messages_202610
```
Kafka 事件必须固定路由信息:
```json
{
"topic": "grp_xxx",
"seq_id": 10087,
"created_at": "2026-10-01T00:00:00.123Z",
"shard_month": "202610",
"client_msg_id": "client_mid_xxx"
}
```
## 5. 群消息落库
### 5.1 生产端
1. 校验群成员、禁言、群状态和内容策略。
2. 使用 Redis 幂等 Key 抢占 `client_msg_id`。
3. 使用 Redis Lua 原子获取 Topic 下一个 `seqid` 和全局 `active_month`。
4. 生成服务端 UTC `created_at`,并把 Lua 返回的月份作为 `shard_month`。
5. 把 `shard_month` 写入 Kafka;所有重试沿用原值。
6. Kafka 生产成功后按现有策略向客户端返回 Seq。
7. 写入 Redis 热消息并执行实时推送。
### 5.2 Kafka 消费端
消费端按 `tenant + shard_month` 分组批量写入:
1. 校验月份格式和允许范围。
2. 从目录确认目标表已存在。
3. 开启事务。
4. 插入或确认 `im_group_message_dedup`。
5. 插入目标月表。
6. 在同一事务中 UPSERT MySQL 路由镜像的最小/最大 Seq、时间和消息数。
7. 更新消费幂等状态并提交事务。
8. 提交成功后更新 Redis `route/route_meta`,并把 Redis 幂等状态改为 `done`。
路由 UPSERT:
```sql
INSERT INTO im_group_message_topic_shards
(topic, shard_month, min_seq, max_seq, message_count,
min_created_at, max_created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE
min_seq = LEAST(min_seq, VALUES(min_seq)),
max_seq = GREATEST(max_seq, VALUES(max_seq)),
message_count = message_count + 1,
min_created_at = LEAST(min_created_at, VALUES(min_created_at)),
max_created_at = GREATEST(max_created_at, VALUES(max_created_at));
```
### 5.3 异常处理
- 目标月表不存在:消费失败、进入重试并高优告警。
- `sealing` 月表:允许携带原 `shard_month` 的延迟事件写入。
- `sealed` 月表:进入补偿队列,人工确认后临时开放或补写。
- 重复 `event_id`:消费日志直接判定成功。
- 重复 `client_msg_id`:返回原 `seqid/shard_month`,不新增消息。
- 超过最大重试次数:进入死信队列,不能静默丢弃。
- Redis 热缓存失败不回滚 MySQL,客户端按 Seq 回源补拉。
- Redis 路由更新失败:进入独立修复重试;查询侧可从 MySQL 镜像重建,不影响已提交消息。
## 6. 消息查询
### 6.1 总流程
```text
客户端 Topic Seq 请求
-> ACL/成员身份校验
-> Redis 24 小时热消息
-> Redis 是否完整覆盖请求范围
-> Redis Topic 月段路由
-> 月表逐张查询
-> 按 seqid 合并、去重、排序、截断
-> 应用 im_dellog 用户视角过滤
-> 返回客户端
```
Redis 只有完整覆盖整页时才能直接返回;有缺口必须回源 MySQL。
### 6.2 最新消息倒序分页
请求 `topic=grp_xxx&before=10088&limit=50`:
1. 从 Redis Topic 月段路由找出 `min_seq < before` 的月份,按月倒序。
2. 先查最新候选月:
```sql
SELECT ...
FROM im_group_messages_202610 AS m
LEFT JOIN im_dellog AS d
ON d.topic = m.topic
AND m.seqid BETWEEN d.low AND d.hi - 1
AND d.deletedfor = ?
WHERE m.topic = ?
AND m.seqid < ?
AND m.status != 'deleted'
AND d.deletedfor IS NULL
ORDER BY m.seqid DESC
LIMIT ?;
```
3. 数量不足时继续查前一个月。
4. 服务层按 `seqid DESC` 合并并截取 `limit`。
5. 下一页游标仍是本页最小 `seqid`,客户端不感知月份。
禁止对所有历史表执行无上限 `UNION ALL`。
### 6.3 Since 正向补拉
1. 从 Redis Topic 月段路由查找 `max_seq > since_seq` 的月份。
2. 按月升序执行 `seqid > since_seq ORDER BY seqid ASC LIMIT N`。
3. 合并后按 Seq 升序返回。
4. 达到上限后返回最后一条 Seq,客户端继续请求。
### 6.4 指定消息
撤回、回复预览、举报取证按 `topic + seqid` 定位:
```text
ZREVRANGEBYSCORE im:{tenant_id}:groupmsg:route:{topic} {seqid} -inf LIMIT 0 1
HGET im:{tenant_id}:groupmsg:route_meta:{topic} {shard_month}
```
先找到最大 `min_seq <= seqid` 的月份,再校验该月段 `max_seq >= seqid`,随后执行 `WHERE topic=? AND seqid=? LIMIT 1`。Redis 路由 Key 缺失时读取 MySQL 镜像并回填 Redis;镜像也缺失时,仅降级扫描当前月和最近若干月并记录修复任务,不允许扫描全部历史表。
### 6.5 上下文和范围
查询目标消息前后各 20 条时:
1. 定位目标所在月。
2. 在本月分别查询目标前后消息。
3. 任一方向不足时继续查询相邻月份。
4. 合并、去重后按 Seq 正序返回。
### 6.6 搜索和统计
- 短期搜索必须带起止时间,最多覆盖 3 个月,逐月查询后归并。
- 中长期通过 Kafka 建独立搜索索引;结果只返回 `topic + seqid`,服务端回源月表并重新校验 ACL。
- Admin 统计使用按日/月汇总表,不实时扫描所有月表执行 `COUNT(*)`。
## 7. 撤回、编辑和删除
撤回、编辑流程:
1. 根据 `topic + seqid` 定位目标月表。
2. 在目标月表事务中 `SELECT ... FOR UPDATE`。
3. 校验版本、权限和状态。
4. 更新目标月表并写修订记录、业务事件。
5. 提交后更新或删除 Redis 热消息。
修订记录建议增加月份:
```sql
ALTER TABLE im_message_revisions
ADD COLUMN shard_month CHAR(6) NULL AFTER topic;
```
用户侧删除继续使用 `im_dellog` 的 Topic Seq 范围,每张月表查询都应用相同过滤。全局删除或合规处置先定位月表,再更新 `status/deleted_at/content/head`;OSS 附件仍走文件生命周期任务。
## 8. Store 层设计
所有动态表访问统一经过路由组件:
```go
type GroupMessageShardRouter interface {
WriteTable(createdAt time.Time, shardMonth string) (string, error)
CandidateTablesBySeq(topic string, seq int64) ([]string, error)
TablesBefore(topic string, before int64) ([]string, error)
TablesSince(topic string, since int64) ([]string, error)
}
```
改造范围:
- `TenantGroupMessageSave`:按事件 `shard_month` 写入。
- `TenantGroupMessageGetOne`:路由后查询目标月。
- `TenantGroupMessageGetAll`:逐月查询并归并。
- `TenantGroupMessageGetByClientID`:先查 Redis/幂等表,再查询目标月。
- `TenantGroupMessageRecall/Edit`:定位目标月后执行事务。
- `TenantMessageSearch`:按时间选月,或转独立搜索。
- Admin 内部消息查询、举报、收藏、回复和群文件引用同步改造。
动态表名必须满足 `^im_group_messages_[0-9]{6}$` 且月份已登记。SQL 参数不能绑定表名,必须严格白名单。
## 9. 旧表迁移
原 `im_group_messages` 先保留,不删除。
推荐步骤:
1. 上线路由表、幂等表和 Shard Manager。
2. 预建历史月份、当月和未来两个月表。
3. 先发布支持“旧表 + 月表”双读的代码,写入仍走旧表。
4. 记录切换水位 `cutover_seq/cutover_time`。
5. 切换新消息只写月表,Kafka 开始携带 `shard_month`。
6. 按 `created_at` 分批把旧表数据迁入对应月表。
7. 每批使用 `INSERT IGNORE`,校验 `topic + seqid`、数量和内容哈希。
8. 回填 MySQL Topic 月段路由镜像,并由重建任务写入 Redis 路由。
9. 双读期间按 `topic + seqid` 去重。
10. 全量校验通过后停止读取旧表。
11. 旧表改名 `im_group_messages_legacy`,只读保留。
12. 观察至少一个完整保留周期后再单独审批清理。
不建议长期双写旧表和月表,否则两个写目标部分成功会扩大一致性问题。
迁移批次要求:
- 使用主键或 `created_at + id` 游标,禁止 OFFSET 深分页。
- 单批 1,000 至 5,000 条,根据线上延迟调整。
- 每批独立事务,记录开始/结束游标和成功/失败数。
- 支持暂停、续跑和重复执行。
## 10. 归档与保留
状态流转:
```text
prepared -> writable -> sealing -> sealed -> archived
```
- `writable`:当月正常写。
- `sealing`:月末后接受延迟消息,默认 7 天。
- `sealed`:在线 MySQL 只读。
- `archived`:转对象存储或低成本库,在线查询走归档服务。
归档前确认 Kafka 无积压/死信、路由完整、消息数量和 Seq 范围一致,并确认举报、审计、收藏和群文件引用仍可定位。
## 11. 监控与告警
| 指标 | 说明 |
| --- | --- |
| `group_message_persist_total{month,status}` | 各月落库成功/失败 |
| `group_message_persist_latency` | Kafka 到 MySQL 提交耗时 |
| `group_message_shard_missing_total` | 目标月表不存在 |
| `group_message_query_tables` | 单次请求扫描表数 |
| `group_message_query_latency{source}` | Redis、单月、跨月耗时 |
| `group_message_dedup_hit_total` | 幂等命中 |
| `group_message_route_miss_total` | Redis 路由缺失 |
| `group_message_route_rebuild_total{status}` | 从 MySQL 镜像重建 Redis 路由 |
| `group_message_route_repair_backlog` | Redis 路由修复积压 |
| `group_message_dead_letter_total` | 落库死信 |
未来月表未创建、当月不可写、`active_month` 缺失、Kafka 延迟超限、单次扫描超过 3 张表、Redis 路由缺失或修复积压增长时必须告警。
## 12. 验收测试
1. 月末和月初消息落入正确表,Seq 连续。
2. Kafka 次月才消费时仍写事件指定月份。
3. 同一 `client_msg_id` 跨月重试只保留一条。
4. 最新 50 条跨两个月时数量、顺序、游标正确。
5. `since_seq` 跨月补拉无遗漏、无重复。
6. `topic + seq` 可定位历史消息。
7. 回复上下文可跨月读取。
8. 撤回、编辑、举报、收藏、群文件引用可定位旧月。
9. 用户删除范围跨月时过滤正确。
10. Redis 热消息不完整时正确回源。
11. 月表缺失时进入重试/死信并告警。
12. 迁移双读无重复,迁移任务可暂停、续跑。
13. 万人群高并发时 Seq 唯一且落库稳定。
14. 非法月份和非法动态表名被拒绝。
15. 多节点跨月并发时 Lua 返回同一活动月份,Seq 不重号且月段不交叠。
16. 删除 Redis 路由后可从 MySQL 镜像自动重建,查询结果不缺失。
## 13. 实施阶段
### 第一阶段:基础设施
- 建月表模板、目录、MySQL 路由镜像和跨月幂等表。
- 实现 Shard Manager,预建未来两个月。
- 建立长期不过期的 Redis Topic 月段路由和原子 `seqid + active_month` Lua。
- Kafka 群消息事件增加 `shard_month`。
### 第二阶段:写入切换
- 消费者按月落库。
- 完成幂等、重试、死信、MySQL 镜像和 Redis 路由更新。
- Redis 热消息与实时推送保持不变。
### 第三阶段:查询切换
- 完成精确查询、倒序分页、Since 补拉和上下文跨月查询。
- 改造撤回、编辑、删除、举报及 Admin 内部查询。
- 增加跨月查询监控。
### 第四阶段:历史迁移
- 上线旧表/月表双读。
- 回填历史消息与 MySQL 路由镜像,重建 Redis Topic 路由。
- 校验后把旧表改为只读保留。
### 第五阶段:搜索和归档
- 消息搜索切换独立索引或限制时间范围。
- 建立 `sealed/archived` 生命周期和历史恢复机制。
## 14. 结论
群消息按月分表不能只改写入表名,必须一起实现固定 `shard_month`、Redis Topic 月段路由、MySQL 路由镜像、跨月幂等、逐月游标查询、历史消息定位和旧表迁移。
最终链路:
```text
发送:Redis Lua 原子分配 Seq 和 active_month -> Kafka 固定 shard_month -> MySQL 月表
实时:Redis Pub/Sub/Stream -> WebSocket
热读:Redis 24 小时消息
冷读:Redis Topic 月段路由 -> MySQL 月表逐月查询
容灾:MySQL 路由镜像 -> 重建长期 Redis 路由
补偿:客户端按 Topic Seq 拉取
```
客户端协议无需增加月份字段,月表路由完全由服务端处理。
+2 -2
View File
@@ -41,7 +41,7 @@ ServerComMessage
2. `sess.bkgTimer` — 后台超时定时器
3. `sess.stop` — 停止信号
4. `sess.detach` — 取消订阅
5. `ticker` — Ping 帧(每 30s 发送一次)
5. `ticker` — Ping 帧(每 15s 发送一次)
### Keep-Alive
@@ -49,7 +49,7 @@ ServerComMessage
|------|-----|------|
| `writeWait` | 10s | 写超时 |
| `pongWait` | `idleSessionTimeout` | 期望收到 Pong 的时间(当前默认 45s) |
| `pingPeriod` | 30s | WebSocket Ping 间隔 |
| `pingPeriod` | 15s | WebSocket Ping 间隔 |
| 心跳探测 | WebSocket Ping/Pong | 当前 IM 长连接保活以协议层 Ping/Pong 为准;业务 `note kp/kpa/kpv` 不能替代 Pong |
### 缓冲区设置
+7 -7
View File
@@ -174,8 +174,8 @@ Tinode 的心跳配置:
```go
const (
writeWait = 10 * time.Second // 写操作超时
pongWait = idleSessionTimeout // 等待 Pong 超时(默认 60 秒)
pingPeriod = (pongWait * 9) / 10 // 发送 Ping 周期(54 秒)
pongWait = idleSessionTimeout // 等待 Pong 超时(默认 45 秒)
pingPeriod = 15 * time.Second // 发送 Ping 周期(15 秒)
)
```
@@ -190,7 +190,7 @@ const (
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ for { │ │
│ │ select { │ │
│ │ case <-ticker.C: // 每 54 秒 │ │
│ │ case <-ticker.C: // 每 15 秒 │ │
│ │ ws.WriteMessage(PingMessage, nil) // 发送 Ping │ │
│ │ case msg <- sess.send: // 有消息待发送 │ │
│ │ ws.WriteMessage(TextMessage, msg) // 发送消息 │ │
@@ -554,8 +554,8 @@ func (sess *Session) writeLoop() {
| 参数 | 值 | 说明 |
|------|-----|------|
| `writeWait` | 10秒 | 写操作超时 |
| `pongWait` | 配置项 | 等待 Pong 超时(默认 60 秒) |
| `pingPeriod` | `pongWait * 0.9` | 发送 Ping 周期 |
| `pongWait` | `idleSessionTimeout` | 等待 Pong 超时(默认 45 秒) |
| `pingPeriod` | 15 秒 | 发送 Ping 周期 |
### 消息格式
@@ -1290,7 +1290,7 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
"listen": ":6060",
"grpc_listen": ":16060",
"max_message_size": 262144,
"idle_session_timeout": 60000,
"idle_session_timeout": 45000,
"ws_compression": true,
"api_key_salt": "your-hmac-salt",
@@ -1350,4 +1350,4 @@ curl "http://localhost:6060/v0/channels/lp?sid=SESSION_ID"
| `pbx/model.proto` | gRPC 协议定义 |
| `server/http.go` | HTTP 服务器 |
| `server/session.go` | 会话管理 |
| `server/datamodel.go` | 消息结构定义 |
| `server/datamodel.go` | 消息结构定义 |
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS `im_content_moderation_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`request_id` VARCHAR(64) NOT NULL,
`scene` VARCHAR(32) NOT NULL,
`topic` VARCHAR(96) NOT NULL DEFAULT '',
`seqid` BIGINT DEFAULT NULL,
`actor_id` BIGINT DEFAULT NULL,
`provider` VARCHAR(32) NOT NULL DEFAULT '',
`provider_request_id` VARCHAR(96) NOT NULL DEFAULT '',
`risk_level` VARCHAR(16) NOT NULL DEFAULT 'none',
`risk_labels` JSON DEFAULT NULL,
`action` VARCHAR(16) NOT NULL,
`content_hash` CHAR(64) NOT NULL,
`latency_ms` BIGINT NOT NULL DEFAULT 0,
`error_code` VARCHAR(64) NOT NULL DEFAULT '',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_content_moderation_request` (`request_id`),
KEY `idx_im_content_moderation_scene_time` (`scene`,`created_at` DESC,`id` DESC),
KEY `idx_im_content_moderation_actor_time` (`actor_id`,`created_at` DESC,`id` DESC),
KEY `idx_im_content_moderation_topic_seq` (`topic`,`seqid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,5 @@
-- IM 消息变更同步水位
-- 用于撤回/编辑等 seq 不变的原地消息变更离线补偿。
ALTER TABLE im_topics
ADD COLUMN mutation_version BIGINT NOT NULL DEFAULT 0 AFTER subcnt;
@@ -0,0 +1,15 @@
-- 在每个租户业务库执行。用于并发安全地生成 im_users.account_no。
CREATE TABLE IF NOT EXISTS im_user_account_sequences (
id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 兼容已有用户数据,从当前最大账号编号继续递增,账号编号最小为 1000。
INSERT INTO im_user_account_sequences (id, next_value)
SELECT 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM im_users
ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value));
+3
View File
@@ -1,3 +1,6 @@
-- Legacy single-table baseline. New deployments must also run
-- IM-群消息按月分表SQL.sql; this table remains only for migration-time dual reads.
CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(64) NOT NULL COMMENT '群 Topic,如 grp_xxx',
+107
View File
@@ -0,0 +1,107 @@
-- IM group-message monthly sharding, baseline month: 2026-09 (UTC).
-- Run in each tenant database. The application pre-creates subsequent months.
CREATE TABLE IF NOT EXISTS im_group_message_shards (
shard_month CHAR(6) NOT NULL,
table_name VARCHAR(64) NOT NULL,
shard_start DATETIME(3) NOT NULL,
shard_end DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'prepared',
row_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NULL,
max_created_at DATETIME(3) NULL,
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 (shard_month),
UNIQUE KEY uk_im_group_message_shards_table (table_name),
KEY idx_im_group_message_shards_status (status, shard_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
topic VARCHAR(64) NOT NULL,
shard_month CHAR(6) NOT NULL,
min_seq BIGINT NOT NULL,
max_seq BIGINT NOT NULL,
message_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NOT NULL,
max_created_at DATETIME(3) NOT NULL,
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 (topic, shard_month),
KEY idx_im_group_message_topic_shards_seq (topic, max_seq),
KEY idx_im_group_message_topic_shards_month (shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_message_dedup (
topic VARCHAR(64) NOT NULL,
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL,
shard_month CHAR(6) NOT NULL,
message_id BIGINT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
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 (topic, from_user, client_msg_id),
KEY idx_im_group_message_dedup_expires (expires_at),
KEY idx_im_group_message_dedup_topic_seq (topic, seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
message_id BIGINT NOT NULL,
topic VARCHAR(64) NOT NULL,
group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL,
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NULL,
msg_type VARCHAR(32) NOT NULL DEFAULT 'text',
status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON NULL,
head JSON NULL,
mentioned_user_ids JSON NULL,
mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT NULL,
root_seqid BIGINT NULL,
file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0,
has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass',
version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3) NULL,
recalled_by BIGINT NULL,
deleted_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (message_id),
UNIQUE KEY uk_202609_group_seq (group_id, seqid),
UNIQUE KEY uk_202609_topic_seq (topic, seqid),
UNIQUE KEY uk_202609_sender_client (topic, from_user, client_msg_id),
KEY idx_202609_group_status_seq (group_id, status, seqid),
KEY idx_202609_topic_created (topic, created_at),
KEY idx_202609_topic_from_seq (topic, from_user, seqid),
KEY idx_202609_audit_created (audit_status, created_at),
KEY idx_202609_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_messages_202608 LIKE im_group_messages_202609;
CREATE TABLE IF NOT EXISTS im_group_messages_202610 LIKE im_group_messages_202609;
CREATE TABLE IF NOT EXISTS im_group_messages_202611 LIKE im_group_messages_202609;
INSERT INTO im_group_message_shards
(shard_month,table_name,shard_start,shard_end,status)
VALUES
('202608','im_group_messages_202608','2026-08-01 00:00:00.000','2026-09-01 00:00:00.000','sealing'),
('202609','im_group_messages_202609','2026-09-01 00:00:00.000','2026-10-01 00:00:00.000','writable'),
('202610','im_group_messages_202610','2026-10-01 00:00:00.000','2026-11-01 00:00:00.000','prepared'),
('202611','im_group_messages_202611','2026-11-01 00:00:00.000','2026-12-01 00:00:00.000','prepared')
ON DUPLICATE KEY UPDATE
table_name=VALUES(table_name),
shard_start=VALUES(shard_start),
shard_end=VALUES(shard_end),
status=IF(status IN ('sealed','archived'),status,VALUES(status));
-- Historical migration is deliberately separate from schema deployment.
-- Backfill im_group_messages into the matching month table in bounded batches,
-- then populate im_group_message_topic_shards before removing legacy reads.
@@ -0,0 +1,96 @@
-- Keep device push token schema aligned across tenant business databases.
-- The current test environment uses:
-- default -> tinode
-- system -> meeting_im
SET @im_schema = 'tinode';
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN sdk_version VARCHAR(64) NOT NULL DEFAULT '''' AFTER platform'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_device_push_tokens'
AND column_name = 'sdk_version'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN push_env VARCHAR(32) NOT NULL DEFAULT '''' AFTER sdk_version'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_device_push_tokens'
AND column_name = 'push_env'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN notification_permission VARCHAR(32) NOT NULL DEFAULT '''' AFTER push_env'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_device_push_tokens'
AND column_name = 'notification_permission'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_schema = 'meeting_im';
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN sdk_version VARCHAR(64) NOT NULL DEFAULT '''' AFTER platform'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_device_push_tokens'
AND column_name = 'sdk_version'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN push_env VARCHAR(32) NOT NULL DEFAULT '''' AFTER sdk_version'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_device_push_tokens'
AND column_name = 'push_env'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN notification_permission VARCHAR(32) NOT NULL DEFAULT '''' AFTER push_env'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_device_push_tokens'
AND column_name = 'notification_permission'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
@@ -0,0 +1,37 @@
-- Add personal privacy switch for requiring approval when others add the user as a friend.
-- Default is on to preserve the current "friend request needs approval" behavior.
-- Run this for every tenant business database. The current test environment uses:
-- default -> tinode
-- system -> meeting_im
SET @im_schema = 'tinode';
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_user_privacy_settings'
AND column_name = 'friend_request_verification'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_schema = 'meeting_im';
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_user_privacy_settings'
AND column_name = 'friend_request_verification'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
@@ -1,14 +1,34 @@
-- Add personal privacy switch for requiring approval when others invite the user into a group.
-- Default is off to preserve existing behavior.
-- Run this for every tenant business database. The current test environment uses:
-- default -> tinode
-- system -> meeting_im
SET @im_schema = 'tinode';
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts',
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = 'tinode'
WHERE table_schema = @im_schema
AND table_name = 'im_user_privacy_settings'
AND column_name = 'group_invite_verification'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_schema = 'meeting_im';
SET @im_sql = (
SELECT IF(
COUNT(*) = 0,
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts'),
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @im_schema
AND table_name = 'im_user_privacy_settings'
AND column_name = 'group_invite_verification'
);
@@ -0,0 +1,125 @@
# IM system 租户接口冒烟记录 - 2026-09-16
## 环境
- Base URL: `https://api-test.118im.com`
- Tenant Code: `system`
- API Key: 新测试环境 Key,接口解析返回的 WebSocket 地址可用
- 测试账号: 本次临时注册 `s832453991` / `17832453991`
- 测试时间: `2026-09-16 11:40 CST`
## 结论
`system` 租户登录、注册、WebSocket 登录和大部分登录后接口可用;本轮确认 1 个确定后端问题:
`GET /v1/im/me/privacy` 在 `system` 租户下返回 `500 INTERNAL_ERROR`。该问题已在 `2026-09-16 12:27 CST` 通过补齐 `meeting_im.im_user_privacy_settings.group_invite_verification` 字段修复,复测返回 200。
初步根因是 `system` 租户实际业务库 `meeting_im` 的 `im_user_privacy_settings` 表缺少 `group_invite_verification` 字段,而当前代码查询和更新都会使用该字段。
## 确认通过
| 接口 | 结果 |
| --- | --- |
| `POST /v1/im/tenants/resolve` | 200,返回 `tenant.code=system` |
| `POST /v1/im/auth/verification-codes` | 200 |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | 200,固定验证码 `123456` 可用 |
| WebSocket `hi` | 201 |
| WebSocket `acc user:new` | 200,新账号创建成功 |
| WebSocket `login scheme=token` | 200 |
| WebSocket `sub me` | 200 |
| `GET /v1/im/bootstrap` | 200 |
| `GET /v1/im/me` | 200 |
| `GET /v1/im/me/profile` | 200 |
| `GET /v1/im/security/realname` | 200 |
| `GET /v1/im/workbench` | 200,返回 `enterprise.tenant_code=system` |
| `GET /v1/im/organization/departments` | 200 |
| `GET /v1/im/organization/members?limit=20` | 200 |
| `GET /v1/im/organization/search?q=1&limit=20` | 200 |
| `GET /v1/im/attendance/config` | 200 |
| `GET /v1/im/attendance/records?limit=20` | 200 |
| `GET /v1/im/meeting-notes?limit=20` | 200 |
| `GET /v1/im/meetings?limit=20` | 200 |
| `GET /v1/im/reports/abuse/reasons?target_type=user` | 200 |
| `GET /v1/im/reports/abuse/reasons?target_type=group` | 200 |
| `GET /v1/im/reports/daily/statistics?date=2026-09-16` | 200 |
| `GET /v1/im/reports/daily?limit=20` | 200 |
| `GET /v1/im/reports/monthly/statistics?month=2026-09` | 200 |
| `GET /v1/im/reports/monthly?limit=20` | 200 |
| `GET /v1/im/executive-mailbox/config` | 200 |
| `GET /v1/im/feedback/config` | 200 |
| `GET /v1/im/app/releases/latest?platform=ios&current_version=1.0.0&channel=stable` | 200 |
| `GET /v1/im/search/conversations?q=&limit=20` | 200 |
| `GET /v1/im/forward-targets?q=&limit=20` | 200 |
| `GET /v1/im/system-notifications?limit=20` | 200 |
| `GET /v1/im/favorites?limit=20` | 200 |
| `GET /v1/im/groups?limit=20` | 200 |
| `GET /v1/im/calls?limit=20` | 200 |
## 问题记录
### P0: `GET /v1/im/me/privacy` 返回 500
请求:
```http
GET /v1/im/me/privacy
X-Tenant-Code: system
X-IM-Schema: 1
Authorization: Bearer <system 用户 token>
```
响应:
```json
{
"code": 90000,
"reason": "INTERNAL_ERROR",
"message": "internal error"
}
```
数据库检查:
- `hotgo.im_tenant` 中 `system` 租户:`db_name=meeting_im`,`db_dsn=''`。
- `meeting_im.im_user_privacy_settings` 当前字段包含:
- `id`
- `user_id`
- `add_me_methods`
- `profile_visibility`
- `last_seen_visibility`
- `read_receipts`
- `version`
- `created_at`
- `updated_at`
- `tinode.im_user_privacy_settings` 比 `meeting_im` 多一个字段:
- `group_invite_verification`
代码使用点:
- `/Users/youshunlai/go_workspace/im_server/server/db/mysql/tenant_adapter.go:6613`
- `/Users/youshunlai/go_workspace/im_server/server/db/mysql/tenant_adapter.go:7194`
判断:
`system` 租户库 `meeting_im` 未执行最新隐私设置表结构迁移。当前代码查询 `group_invite_verification` 字段时会在该库报 SQL 错误,最终 HTTP 统一包装成 `500 INTERNAL_ERROR`。
处理结果:
- 测试库 `mysql.118im.com:30306/tinode` 已有该字段。
- 测试库 `mysql.118im.com:30306/meeting_im` 已补字段。
- 本地库 `127.0.0.1:3306/tinode` 已补字段。
- 本地库 `127.0.0.1:3306/meeting_im` 已补字段。
- 复测 `GET /v1/im/me/privacy` 返回 200,响应包含 `group_invite_verification:false`。
## 误测排除
以下接口第一次测试时返回 400/405,是因为请求参数或方法不完整,不属于本轮确认问题:
- `GET /v1/im/reports/abuse/reasons`:需要 `target_type=user|group`。
- `GET /v1/im/reports/daily/statistics`:需要 `date=YYYY-MM-DD`。
- `GET /v1/im/reports/monthly/statistics`:需要 `month=YYYY-MM`。
- `GET /v1/im/reports/daily/reminders`:实现为 `POST`,GET 返回 405 属正常方法限制。
## 额外注意
旧 API Key 访问 `system` 或 `default` 都返回 `403 API_KEY_REQUIRED`;新测试环境应使用租户解析接口返回的新 Key 和 WebSocket 地址。
@@ -0,0 +1,55 @@
# IM 群消息按月分表本地验收报告
## 验收环境
- 时间:2026-09-15
- IM Server:`http://127.0.0.1:6060`
- WebSocket:`ws://127.0.0.1:6060/v0/channels`
- MySQL:`127.0.0.1:3306`,租户库 `tinode`
- Redis:`127.0.0.1:6379`
- Kafka:`127.0.0.1:9092`
## 验收结果
| 项目 | 结果 | 说明 |
| --- | --- | --- |
| 编译、定向单测、Go Vet | 通过 | MySQL、Store、Server 本次变更相关用例全部通过 |
| WebSocket 协议审计 | 通过 | 29/29,覆盖注册、登录、P2P、群聊、文件、回执、通话与异常鉴权 |
| HTTP 接口审计 | 通过 | 98/98,未发现非预期失败或非预期 404 |
| Kafka 群消息异步落库 | 通过 | 消费成功,消费组 LAG 为 0 |
| 群消息分月写入 | 通过 | 新消息写入 `im_group_messages_202609`,未写入旧表 `im_group_messages` |
| Kafka 重放幂等 | 通过 | 同一消息使用新事件 ID 重放后仍只有一行,路由计数不重复增加 |
| Redis Seq | 通过 | 群 Topic Seq 原子增长,键 TTL 为 `-1` |
| Redis 月份路由 | 通过 | 路由和元数据完整,键 TTL 为 `-1` |
| 服务重启后历史拉取 | 通过 | 清除进程内 Topic 后,客户端可从分表恢复历史消息 |
| 跨月合并查询 | 通过 | 构造 202608、202609 两个月份数据后,一次订阅正确返回两个分表的消息 |
| 并发注册与群消息冒烟 | 通过 | 3 组并发、每组 29/29;账号编号无重复,群消息全部落入月表 |
| Manager 内部接口 | 通过 | 56/56,覆盖列表、详情、分页、筛选、用户创建及回读 |
| Manager HMAC 安全 | 通过 | 未签名、错签名、过期签名均返回 401,重复 Nonce 第二次返回 401 |
## 测试中修复
1. 注册写入未生成 `im_users.account_no`,第二个新用户会命中唯一键冲突。现改为租户库单行序列表原子分配。
2. Kafka 群消息生产前未分配持久化 `message_id`,消费者会判定消息格式不完整。现由生产端生成并随事件固定传递。
3. HTTP 审计工具过早执行退出登录,导致后续接口误报 401。现将退出登录移到整轮审计末尾。
4. WebSocket 审计工具并发运行时可能生成相同手机号。现将进程 ID 纳入测试数据后缀。
5. 个人资料刷新错误地向订阅表写入不存在的 `public` 字段。现改为清理好友订阅缓存并在下次读取时重新投影。
6. `Uid` 零值序列化为空字符串后无法反序列化,导致群配置缓存持续回源。现已补齐零值 JSON 往返支持及单测。
7. Manager 的看板和群消息查询仍只统计历史总表。现已按分片元数据合并月表与未迁移历史数据。
8. 群消息历史表与月表 collation 不一致,`UNION` 查询返回 500。现已在历史分支显式统一 collation。
9. Manager 群消息详情、文件详情和审计日志仍使用旧表或旧字段口径。现已支持月表详情、19 位数字文件 ID,并完成审计字段映射。
10. Manager 群列表混入 `p2p` 和 `slf` Topic。现已在数据源层限定仅返回 `grp` Topic。
11. Manager 内部查询输出的用户相关 ID 已统一为数据库 `BIGINT/int64`,不再转换为 `usr...`。
## Manager 内部接口验收
- 基础查询:用户、群组、私聊消息、群消息、文件、登录日志、审计、实名、举报、反馈、配置、工作台、组织架构和看板均返回 200。
- 群治理查询:群详情、成员、入群申请、邀请、公告、禁言、管理日志和群文件均通过。
- 运维查询:业务 Outbox、群 Outbox、Kafka 死信列表及详情均通过。
- 写入冒烟:`POST /internal/admin-query/tenants/default/users` 创建成功,返回数字型 `user_id`,随后详情回读成功。
- 协议口径:`user_id`、`owner_id`、`from_user_id`、`file_id` 和 `seq_id` 均按 JSON number 返回;群列表仅包含 `grp` Topic。
- 安全边界:HMAC 签名覆盖 GET/POST,`X-Nonce` 为一次性值,防重放状态由 Redis 保持。
## 结论
本次群消息按月分表相关的写入、读取、重启恢复、跨月查询、Kafka 幂等、Redis 路由及并发链路均已通过本地验收;Manager 内部接口 56/56 及 HMAC 安全用例也已全部通过,可以进入测试环境验证。外部 OSS、内容安全、Push 和真实音视频媒体质量不属于本轮本地验收范围。
@@ -0,0 +1,78 @@
# IM 群聊测试环境验收报告
## 1. 测试信息
- 测试时间:2026-09-16
- HTTP:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels`
- 租户:`default`
- 数据库:测试环境租户库 `tinode`
- Redis:测试环境 Redis
- Kafka:测试环境 Kafka
- API Key、Token、数据库与中间件凭据未写入报告。
## 2. 验收结论
群聊主要链路通过;群主转让测试发现一处群设置缓存版本未失效问题,代码已修复,等待重新发布后复验转让与解散:
- 群业务 HTTP 深度审计:基础及治理、审批项 `41/41` 通过。
- 群 WebSocket、消息、文件专项:`16/16` 通过。
- 群实时收发核心路径额外连续执行 3 轮,`24/24` 通过。
- 群消息 Kafka 消费组 lag 为 `0`。
- 群主转让:测试环境当前版本失败,返回 `409 VERSION_CONFLICT`;已定位并修复,需发布复验。
- 群解散:依赖转让成功后的新群主身份,本轮未能完成最终测试环境验收。
## 3. 功能结果
| 范围 | 结果 | 验证内容 |
| --- | --- | --- |
| 群基础管理 | 通过 | 创建、列表、详情、资料修改、个人群设置、群昵称、成员列表与成员资料 |
| 群治理 | 通过 | 治理配置读取与成功更新,普通成员越权返回 403,版本号递增 |
| 管理员 | 通过 | 管理员数量限制、设置管理员、管理员修改治理配置、取消管理员 |
| 禁言 | 通过 | 群主禁言、成员查询自身禁言、被禁言 WS 发消息返回 403、解禁后立即恢复发送 |
| 公告 | 通过 | 发布公告、公告详情、成员已读 |
| 入群审批 | 通过 | 非成员申请、普通成员不可读审批、群主查询并通过、新成员进群后可访问、主动退群 |
| 实时消息 | 通过 | 双端订阅、双向广播、历史拉取,连续 3 轮均成功 |
| 消息幂等 | 通过 | 相同 `x-im-client-mid` 重复发送返回同一 `seq`,数据库仅保留一条消息 |
| 消息查询 | 通过 | 消息上下文、聊天记录搜索 |
| 消息撤回 | 通过 | 发送者在时限内撤回,状态变为 `recalled`,版本由 1 递增为 2 |
| 群文件 | 通过 | 文件上传 OSS、关联群文件、成员检索、群主删除关联 |
| 权限隔离 | 通过 | 普通成员修改治理、读取管理限制、禁言他人、读取审批列表均被拒绝 |
| 群主转让 | 待复验 | 二次验证成功,但读取到缓存版本 4,MySQL 实际版本为 6,提交返回版本冲突 |
| 群解散 | 待复验 | 转让失败后目标用户不是群主,解散按权限规则返回 403 |
## 4. 数据链路核验
最终消息验证群:`grpMQrTw9wvUlA`。
| 检查项 | 结果 |
| --- | --- |
| 月表落库 | `im_group_messages_202609` 存在 `seq=1,2,3` 三条消息 |
| 旧表隔离 | `im_group_messages` 中该群消息数量为 0 |
| 撤回状态 | `seq=1` 为 `recalled`,`version=2` |
| 幂等记录 | `dedup-16737970` 对应 `seq=2`,状态 `done`,无重复行 |
| 月段路由 | `202609`,`min_seq=1`,`max_seq=3`,`message_count=3` |
| Redis 原子序号 | `last_seq=3` |
| Redis 热消息索引 | 包含 `seq=1,2,3` |
| Redis 月段路由 | `202609 -> 1`,元数据为 `1:3:3` |
| Kafka 异步落库 | `im-server.group_message_persist` 检查时当前 offset 62、末端 offset 62、lag 0 |
| 禁言业务状态 | 三轮禁言记录均由 `active` 正常流转为 `revoked`,版本为 2 |
## 5. 测试数据
本轮创建了多组 `a*`、`b*`、`c*` 自动化用户和 `grp*` 自动化群,均位于测试租户 `default`。数据用于回归追踪,后续可按账号前缀和测试时间清理。
## 6. 补充说明
综合 WebSocket 审计中观察到一次 P2P `read` 回执等待超时;群聊段已改为独立重连后执行,所有群聊用例稳定通过。该现象不影响本报告的群聊验收结论,但建议后续单独对 P2P 回执跨节点投递做压力与稳定性测试。
## 7. 已定位问题与修复
入群审批成功和成员退群都会在 MySQL 中重算人数并递增 `im_group_settings.version`,但原实现只清理成员分页缓存,没有清理 Redis `group_settings` 对象缓存。群治理接口因此返回旧版本,导致后续群主转让稳定触发版本冲突。
本轮已在成员直接邀请、接受邀请、审批通过、主动退群和群主转让后统一清理群设置缓存,并增加缓存键单元测试。相关定向测试通过:
- `go test ./server/store ./server/db/mysql`
- `go test ./server -run 'TestServeIMGroup|TestGroup' -count=1`
修复尚未发布到测试环境;发布后应重新执行“读取最新版本 -> 群主转让 -> 新群主解散 -> 旧成员读取返回 410”闭环。
@@ -411,7 +411,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 |
| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 |
| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 |
| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
| 拉黑/取消拉黑 | 操作人本人 | 拉黑:`{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}`;取消拉黑:`{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.unblocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
@@ -1377,7 +1377,9 @@ 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}` |
| `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"}` |
| `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` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"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/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
| `GET /v1/im/topics/{topic}/message-mutations?since_version=&limit=` | 拉取撤回/编辑等原地消息变更;`since_version` 取本地已应用的会话级 `mutation_version` | `{"topic":"grp...","items":[{"seq":12,"version":2,"event":"message.recalled","operator":"usr...","mutated_at":"2026-08-20T09:00:00Z","mutation_version":3}],"next_cursor":"3","has_more":false,"mutation_version":3,"expired":false}` |
| `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/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":""}` |
@@ -1385,7 +1387,226 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` |
| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。批量撤回按成功项逐条产生 `message.recalled` 事件;失败项不会产生事件。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
#### 4.21.2 群消息撤回与批量撤回
群聊撤回使用 Topic 消息操作接口,`topic` 必须传真实群 Topic(`grp...`),不能传用户 ID、群 ID 昵称或本地会话别名。单条撤回和多选批量撤回都要求登录态,Header 至少包含 `Authorization: Bearer <token>`、`X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`。
单条撤回:
```http
POST /v1/im/topics/{topic}/messages/{seq}/recall
```
Path 参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
| `seq` | Int64 | 是 | 要撤回的消息序号。 |
Body:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `version` | Int | 否 | 期望消息版本。不传时后端使用当前消息版本;传旧版本可能返回版本冲突。 |
| `reason` | String | 否 | 撤回原因,当前主要用于客户端/审计扩展,可传空。 |
单条请求示例:
```json
{
"version": 1,
"reason": "mistake"
}
```
单条成功响应 `data`:
```json
{
"topic": "grp6PIoZgdpc2g",
"seq": 102,
"status": "recalled",
"version": 2,
"updated_at": "2026-09-15T04:30:00Z",
"recalled_at": "2026-09-15T04:30:00Z",
"recalled_by": "usr_actor"
}
```
多选批量撤回:
```http
POST /v1/im/topics/{topic}/messages/recall
```
Path 参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
Body:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `items` | Array | 是 | 要撤回的消息列表,长度 1~50。 |
| `items[].seq` | Int64 | 是 | 要撤回的消息序号。 |
| `items[].version` | Int | 否 | 该消息期望版本。不传时后端使用当前消息版本。 |
| `items[].reason` | String | 否 | 单条撤回原因;可不传。 |
| `reason` | String | 否 | 批量默认撤回原因;单条 `items[].reason` 可覆盖。 |
批量请求示例:
```json
{
"items": [
{
"seq": 102,
"version": 1
},
{
"seq": 103,
"version": 1
}
],
"reason": "mistake"
}
```
批量响应始终按条目返回处理结果。一条失败不影响其他条目;成功条目会包含最新 `version`,失败条目会包含该条失败原因。
```json
{
"topic": "grp6PIoZgdpc2g",
"items": [
{
"topic": "grp6PIoZgdpc2g",
"seq": 102,
"status": "recalled",
"version": 2,
"updated_at": "2026-09-15T04:30:00Z",
"recalled_at": "2026-09-15T04:30:00Z",
"recalled_by": "usr_actor"
},
{
"seq": 103,
"status": "failed",
"http": 409,
"code": 30006,
"reason": "VERSION_CONFLICT",
"message": "message version conflict"
}
]
}
```
常见失败项:
| `http` | `code` | `reason` | 说明 |
| --- | --- | --- | --- |
| `400` | `30000` | `MALFORMED_REQUEST` | `seq` 非法、`items` 为空或超过 50 条。 |
| `403` | `30005` | `PERMISSION_DENIED` | 当前用户无权撤回该消息,或消息已超过撤回窗口。超过窗口时 `message` 为 `message recall window expired`。 |
| `404` | `30004` | `MALFORMED_REQUEST` | 消息不存在。 |
| `409` | `30006` | `VERSION_CONFLICT` | 请求版本与服务端当前消息版本不一致,或消息已撤回/删除。 |
权限与时限:
- 发送人可撤回自己的消息。
- 群主/管理员可撤回群成员消息。
- 当前后端撤回窗口为 120 秒;超过后返回 `message recall window expired`。
- 被撤回消息服务端会清空普通正文,历史拉取应展示撤回占位,不继续展示旧内容。
实时事件:
撤回成功后,Topic 在线成员会收到 `message.recalled`。批量撤回按成功项逐条发送事件,失败项不会发送。
```json
{
"info": {
"topic": "grp6PIoZgdpc2g",
"from": "usr_actor",
"what": "im",
"event": "message.recalled",
"payload": {
"topic": "grp6PIoZgdpc2g",
"seq": 102,
"version": 2
}
}
}
```
客户端处理规则:
- 多选撤回优先调用批量接口,不要并发打很多单条接口。
- 批量接口 HTTP `200` 只表示请求被服务端处理,仍需逐条读取 `items[].status`。
- `status="recalled"` 时,用 `topic + seq + version` 更新本地消息为撤回状态。
- `status="failed"` 时,只对该条提示失败原因,其他成功项正常生效。
- 收到实时事件后也按 `topic + seq + version` 刷新本地消息,避免只依赖发起方本地状态。
#### 4.21.3 消息变更同步水位
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。
会话列表同步时,`me.meta.sub[].private` 会带:
```json
{
"mutation_version": 3
}
```
客户端按账号 + topic 保存本地已应用水位。若服务端 `mutation_version` 大于本地值,调用:
```http
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
```
响应 `data`:
```json
{
"topic": "grp6PIoZgdpc2g",
"items": [
{
"seq": 102,
"version": 2,
"event": "message.recalled",
"operator": "usr_actor",
"mutated_at": "2026-09-15T04:30:00Z",
"mutation_version": 3
}
],
"next_cursor": "3",
"has_more": false,
"mutation_version": 3,
"expired": false
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `seq` | 被变更消息的序号。 |
| `version` | 变更后的消息版本,用于同一 `seq` 的幂等与乱序保护。 |
| `event` | `message.recalled` / `message.edited`,与实时 `info.event` 保持一致。 |
| `operator` | 操作人用户 ID。 |
| `mutated_at` | 变更发生时间。 |
| `mutation_version` | 会话级最新水位。客户端成功应用返回项后推进本地水位。 |
| `next_cursor` | 当前实现等同最新 `mutation_version`,客户端原样保存即可。 |
| `expired` | 当前轻量实现固定为 `false`;后续若服务端裁剪变更记录,该字段会用于提示客户端降级重拉。 |
客户端处理规则:
- `mutation_version` 是会话级水位,不是单条消息的 `version`。
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
## 5. 通讯录、好友关系与黑名单
@@ -1489,7 +1710,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。
- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。
- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `relationship.blocked`;解除拉黑推送 `relationship.unblocked`。事件 `topic` 为操作人自己的 `usr_actor`,`payload.user_id` 为操作人 ID;客户端收到后刷新黑名单、好友列表和会话 context。拉黑后服务端拦截对方消息和呼叫。
- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。
### 5.11 删除好友提示.png
@@ -1694,6 +1915,45 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。
- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。
### 6.13.1 群成员列表拉取与缓存策略
**状态:已按分页 + 服务端页缓存实现。**
客户端进入普通群聊会话时不要主动全量拉取群成员列表;群详情中的 `member_count`、当前用户权限和会话上下文足够渲染聊天页。只有进入群成员页、管理员候选页、禁言候选页、转让群主候选页或输入 `@` 搜索成员时,才调用 `GET /v1/im/groups/{topic}/members`。
请求参数:
| 参数 | 类型 | 用法 |
| --- | --- | --- |
| `topic` | Path string | 群 Topic,例如 `grpORCbFk4mAzk`。 |
| `query` | Query string,可选 | 成员搜索关键字。空或省略表示拉当前筛选条件下的成员页;有值时服务端按群昵称、用户昵称、备注展示名、用户名等可搜索字段匹配。`@` 人、候选人选择器必须优先传 `query`,不要先拉全量再本地筛。 |
| `cursor` | Query string,可选 | 分页游标。第一页省略或传空;下一页必须使用上一次响应的 `next_cursor`,客户端不得自行拼游标。 |
| `limit` | Query int,可选 | 每页数量,默认 `30`,最大 `100`。万人群场景建议普通列表 `30`,搜索/候选页 `30`,不要循环预拉完整群成员。 |
| `mentionable` | Query bool,可选 | `@` 成员选择器传 `true`,服务端只返回当前可被提及的成员,并在响应里返回 `permissions.can_mention_all`;普通成员列表省略或传 `false`。 |
| `role` | Query enum,可选 | 按现有角色筛选:`owner`、`admin`、`member`。例如管理员列表传 `role=admin`。 |
| `eligible_role` | Query enum,可选 | 候选角色视图:`admin` 表示添加管理员候选,`owner` 表示转让群主候选;响应项会带 `eligible`、`ineligible_reason`。 |
| `mute_eligible` | Query bool,可选 | 禁言候选选择器传 `true`;响应项会带 `eligible`、`ineligible_reason`。该参数用于响应阶段计算候选状态,不改变公共成员页缓存维度。 |
响应重点字段:`items[]` 返回 `user_id,display_name,group_nickname,avatar_file_id,role,mentionable,acs,eligible,ineligible_reason`;`next_cursor` 为空表示没有下一页;`permissions.can_mention_all` 只表示当前登录用户是否可以使用 `@所有人`。
典型调用:
| 场景 | 调用方式 | 说明 |
| --- | --- | --- |
| 进入普通群聊 | 不调用成员列表 | 只用群详情、会话上下文和 `member_count`。 |
| 群成员第一页 | `GET /v1/im/groups/{topic}/members?limit=30` | 打开成员列表时拉第一页。 |
| 群成员下一页 | `GET /v1/im/groups/{topic}/members?cursor={next_cursor}&limit=30` | 只使用服务端返回的 `next_cursor`。 |
| 成员搜索 | `GET /v1/im/groups/{topic}/members?query=张&limit=30` | `query` 变化时清空本地结果,从第一页重新拉。 |
| `@` 选择器 | `GET /v1/im/groups/{topic}/members?query=张&mentionable=true&limit=30` | 只展示 `mentionable=true` 的成员;是否展示 `@所有人` 看 `permissions.can_mention_all`。 |
| 管理员列表 | `GET /v1/im/groups/{topic}/members?role=admin&limit=30` | 群设置页展示管理员。 |
| 添加管理员候选 | `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=张&limit=30` | 用 `eligible=false` 和 `ineligible_reason` 禁用不可选项。 |
| 转让群主候选 | `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=张&limit=30` | 只允许选择符合转让条件的成员。 |
| 禁言候选 | `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=张&limit=30` | 管理员选择禁言对象时使用。 |
客户端本地缓存建议按 `tenant_code + my_user_id + topic + query + cursor + limit + role + eligible_role + mentionable + mute_eligible` 建 key,只缓存已加载页,并且只在当前页面生命周期内复用。收到 `group.member.joined`、`group.member.left`、`group.member.updated`、`group.settings.updated` 等群业务事件后,应清理该 `topic` 的本地成员列表、候选人和 `@` 搜索缓存;只发生用户资料/备注变化时,可刷新当前可见页或对应用户资料。
服务端以 `topic + role + eligible_role + query + cursor + limit + mentionable` 作为 Redis 成员页缓存维度,同一页短时间重复请求会命中页缓存。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人实时补齐。成员加入、退出、角色/昵称/状态变化时按 `topic` 清理相关页缓存。该接口不承载在线状态,在线/离线应走 presence 或会话状态逻辑。
### 6.14 群简介.png
<img src="images/群简介.png" alt="群简介" width="360">
@@ -1719,7 +1979,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
@@ -1866,7 +2126,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` |
@@ -2683,7 +2943,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。
- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,friend_request_verification,group_invite_verification,version` 和各字段 `editable`;`friend_request_verification=true` 表示别人加我为好友时需要我确认,`false` 时符合来源规则的好友申请自动通过;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。
- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。
- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。
@@ -2791,8 +3051,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
+3 -2
View File
@@ -4,6 +4,7 @@ go 1.26.0
require (
firebase.google.com/go v3.13.0+incompatible
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
github.com/alibabacloud-go/tea v1.5.2
@@ -21,11 +22,13 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0
github.com/jmoiron/sqlx v1.4.0
github.com/mozillazg/go-pinyin v0.20.0
github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.69.0
github.com/redis/go-redis/v9 v9.12.1
github.com/rivo/uniseg v0.4.7
github.com/segmentio/kafka-go v0.4.49
github.com/tinode/jsonco v1.0.0
github.com/tinode/snowflake v1.0.0
go.mongodb.org/mongo-driver v1.17.9
@@ -44,7 +47,6 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
@@ -82,7 +84,6 @@ require (
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/segmentio/kafka-go v0.4.49 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
+2
View File
@@ -265,6 +265,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+3 -2
View File
@@ -1109,7 +1109,8 @@ CREATE TABLE IF NOT EXISTS `im_messages` (
`head` json DEFAULT NULL,
`content` json DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`)
UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`),
UNIQUE KEY `uk_im_messages_topic_seq` (`topic`,`seqid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_monthly_reports` (
@@ -1316,6 +1317,7 @@ CREATE TABLE IF NOT EXISTS `im_topics` (
`seqid` bigint NOT NULL DEFAULT '0',
`delid` int DEFAULT '0',
`subcnt` int DEFAULT '0',
`mutation_version` bigint NOT NULL DEFAULT '0',
`public` json DEFAULT NULL,
`trusted` json DEFAULT NULL,
`tags` json DEFAULT NULL,
@@ -1616,4 +1618,3 @@ VALUES (2,'system', 'system merchant', 'system default merchant', 'meeting_im',
INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`)
VALUES (2, 0, 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
+29
View File
@@ -167,6 +167,35 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma
return wrapper.Values, nil
}
func (c *adminInternalClient) ModerationProvider() (*types.ModerationProviderConfig, error) {
var result types.ModerationProviderConfig
if err := c.getJSON("/internal/im-config/moderation/provider", nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error) {
var result types.ModerationPolicy
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/moderation-policy"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error) {
var result types.SensitiveWordSet
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/sensitive-words"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
if result.Items == nil {
result.Items = []*types.SensitiveWordRule{}
}
return &result, nil
}
func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
+21
View File
@@ -75,6 +75,12 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
if strings.Contains(strings.ToLower(privateSpec.Select), "m.content as content") || strings.Contains(strings.ToLower(groupSpec.Select), "m.content as content") {
t.Fatal("message list query must not expose full message content")
}
if !strings.Contains(privateSpec.Select, "CAST(m.`from` AS CHAR) AS from_user_id") || !strings.Contains(privateSpec.Select, "CAST(rs.userid AS CHAR) AS to_user_id") {
t.Fatal("private message query must expose 64-bit user IDs as strings")
}
if !strings.Contains(groupSpec.Select, "CAST(m.from_user AS CHAR) AS from_user_id") {
t.Fatal("group message query must expose 64-bit user IDs as strings")
}
}
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
@@ -94,6 +100,20 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
}
}
func TestLoginAuditQuerySpecsExposeUserIDAsString(t *testing.T) {
for label, spec := range map[string]*internalQuerySpec{
"user-login-logs": internalTenantResourceSpecs["user-login-logs"],
"login-failure-logs": internalTenantResourceSpecs["login-failure-logs"],
} {
if spec == nil {
t.Fatalf("%s query spec missing", label)
}
if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") {
t.Fatalf("%s query must expose 64-bit user IDs as strings: %s", label, spec.Select)
}
}
}
func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil)
query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "")
@@ -110,6 +130,7 @@ func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
}
for _, field := range []string{
"CAST(u.id AS CHAR) AS user_id",
"AS avatar",
"u.account_no AS display_no",
"AS register_type",
+18 -1
View File
@@ -10,11 +10,13 @@ package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/store/types"
)
@@ -150,7 +152,8 @@ type MsgClientAcc struct {
Username string `json:"username,omitempty"`
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
Phone string `json:"phone,omitempty"`
// Optional case-sensitive registration invitation code.
// Deprecated compatibility field. New clients put a five-letter invitation
// code in the shared verification input and submit it as the tel cred response.
InviteCode string `json:"invite_code,omitempty"`
// Authenticate session with the newly created account
Login bool `json:"login,omitempty"`
@@ -1618,6 +1621,20 @@ func ErrPolicyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
return ErrPolicyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
}
func ErrContentModerationReply(msg *ClientComMessage, ts time.Time, err error, result *moderation.Result) *ServerComMessage {
reply := ErrPolicyReply(msg, ts)
reason := "CONTENT_REJECTED"
if errors.Is(err, moderation.ErrUnavailable) {
reason = "CONTENT_REVIEW_UNAVAILABLE"
}
reply.Ctrl.Text = "content rejected"
reply.Ctrl.Params = map[string]any{"reason": reason}
if result != nil && strings.TrimSpace(result.BlockMessage) != "" {
reply.Ctrl.Params.(map[string]any)["message"] = result.BlockMessage
}
return reply
}
// ErrCallBusyExplicitTs indicates a "busy" reply to a video call request (486).
func ErrCallBusyExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
return &ServerComMessage{
+4
View File
@@ -245,6 +245,7 @@ type TenantSecurityAuditAdapter interface {
TenantLoginFailureCreate(t.TenantID, *t.LoginFailureEvent) error
TenantLoginAuditClose(t.TenantID, string, time.Time) error
TenantMessageAdminApply(t.TenantID, *t.MessageAdminAction) error
TenantContentModerationLogCreate(t.TenantID, *t.ContentModerationLog) error
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
@@ -332,6 +333,7 @@ type TenantBusinessAdapter interface {
TenantTopicUpdateOnMessage(t.TenantID, string, *t.Message) error
TenantTopicUpdateSubCnt(t.TenantID, string) error
TenantTopicUpdate(t.TenantID, string, map[string]any) error
TenantTopicBumpMutationVersion(t.TenantID, string, time.Time) (int64, error)
TenantTopicOwnerChange(t.TenantID, string, t.Uid) error
TenantSubscriptionGet(t.TenantID, string, t.Uid, bool) (*t.Subscription, error)
@@ -351,6 +353,7 @@ type TenantBusinessAdapter interface {
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantMessageMutations(t.TenantID, string, t.Uid, int64, int) ([]t.MessageMutation, string, bool, int64, error)
TenantGroupMessageSave(t.TenantID, *t.Message) error
TenantGroupMessageGetByClientID(t.TenantID, string, t.Uid, string) (*t.Message, error)
TenantGroupMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
@@ -387,6 +390,7 @@ type TenantBusinessAdapter interface {
TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error
TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error)
TenantGroupMuteGet(t.TenantID, string, t.Uid) (*t.GroupMute, error)
TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error)
TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error
TenantGroupFileList(t.TenantID, string, string, string, string, int) ([]t.GroupFile, string, error)
+113 -60
View File
@@ -9,7 +9,6 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"sort"
"strconv"
@@ -49,6 +48,7 @@ type adapter struct {
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantMu sync.Mutex
groupShardWindow string
}
const (
@@ -375,7 +375,7 @@ func (a *adapter) CheckDbVersion() error {
}
// Version returns adapter version.
func (adapter) Version() int {
func (*adapter) Version() int {
return adpVersion
}
@@ -587,6 +587,8 @@ func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error {
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
aliyun_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable Aliyun content moderation',
local_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable local content moderation',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -1357,6 +1359,7 @@ func createIMUserProductTables(tx *sql.Tx) error {
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
friend_request_verification TINYINT NOT NULL DEFAULT 1,
group_invite_verification TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -1819,26 +1822,19 @@ func (a *adapter) UserCreate(user *t.User) error {
user.Phone = canonicalPhone
phone = canonicalPhone
}
releaseAccountNoLock, err := lockUserAccountNoSequence(ctx, tx)
accountNo, hasAccountNo, err := nextUserAccountNo(ctx, tx)
if err != nil {
return err
}
defer releaseAccountNoLock()
accountNo, err := nextUserAccountNo(ctx, tx)
if err != nil {
return err
insert := "INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)"
args := []any{decoded_uid, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone,
common.ToJSON(user.Public), common.ToJSON(user.Trusted)}
if hasAccountNo {
insert = "INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)"
args = []any{decoded_uid, accountNo, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone,
common.ToJSON(user.Public), common.ToJSON(user.Trusted)}
}
if _, err = tx.Exec("INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)",
decoded_uid,
accountNo,
user.Username,
user.CreatedAt,
user.UpdatedAt,
user.State,
user.Access,
phone,
common.ToJSON(user.Public),
common.ToJSON(user.Trusted)); err != nil {
if _, err = tx.Exec(insert, args...); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
@@ -1859,35 +1855,36 @@ func (a *adapter) UserCreate(user *t.User) error {
return tx.Commit()
}
func lockUserAccountNoSequence(ctx context.Context, tx *sqlx.Tx) (func(), error) {
var locked sql.NullInt64
if err := tx.QueryRowContext(ctx, "SELECT GET_LOCK(CONCAT(DATABASE(), ':im_users_account_no'), 10)").Scan(&locked); err != nil {
if isPXCStrictModeNamedLockError(err) {
return func() {}, nil
}
return nil, err
// nextUserAccountNo allocates a tenant-local, human-facing account number.
// The sequence row serializes concurrent registrations across server nodes.
func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (uint32, bool, error) {
var columns int
if err := tx.GetContext(ctx, &columns, `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='im_users' AND COLUMN_NAME='account_no'`); err != nil {
return 0, false, err
}
if !locked.Valid || locked.Int64 != 1 {
return nil, fmt.Errorf("lock user account number sequence timeout")
if columns == 0 {
return 0, false, nil
}
return func() {
_, _ = tx.ExecContext(ctx, "SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':im_users_account_no'))")
}, nil
}
func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (int64, error) {
var current sql.NullInt64
err := tx.QueryRowContext(ctx, "SELECT account_no FROM im_users ORDER BY account_no DESC LIMIT 1 FOR UPDATE").Scan(&current)
if err == sql.ErrNoRows {
return 1000, nil
}
result, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
SET next_value=LAST_INSERT_ID(next_value+1) WHERE id=1`)
if err != nil {
return 0, err
return 0, true, err
}
if !current.Valid || current.Int64 < 999 {
return 1000, nil
if affected, affectedErr := result.RowsAffected(); affectedErr != nil {
return 0, true, affectedErr
} else if affected != 1 {
return 0, true, errors.New("mysql: user account sequence is not initialized")
}
return current.Int64 + 1, nil
var next uint64
if err = tx.GetContext(ctx, &next, "SELECT LAST_INSERT_ID()"); err != nil {
return 0, true, err
}
if next > uint64(^uint32(0)) {
return 0, true, errors.New("mysql: user account number exhausted")
}
return uint32(next), true, nil
}
// TenantAuthAddRecord adds the user's Basic authentication record.
@@ -2747,7 +2744,7 @@ func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
// Fetch topic by name
var tt = new(t.Topic)
if err := a.db.GetContext(ctx, tt,
"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted,tags,aux "+
"FROM im_topics WHERE name=?", topic); err != nil {
if err == sql.ErrNoRows {
// Nothing found - clear the error
@@ -2901,7 +2898,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
// Fetch grp topics and join to subscriptions.
if len(topq) > 0 {
q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " +
q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted " +
"FROM im_topics WHERE name IN (?)"
q, args, _ = sqlx.In(q, topq)
@@ -2943,6 +2940,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
sub.SetState(top.State)
sub.SetTouchedAt(top.TouchedAt)
sub.SetSeqId(top.SeqId)
sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion)
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
sub.SetSubCnt(top.SubCnt)
sub.SetPublic(common.FromJSON(top.Public))
@@ -4765,31 +4763,44 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er
if err != nil {
return nil, err
}
defer rows.Close()
var locations []string
var ids []any
type gcCandidate struct {
id int
location string
}
var candidates []gcCandidate
for rows.Next() {
var id int
var loc string
if err = rows.Scan(&id, &loc); err != nil {
break
}
candidates = append(candidates, gcCandidate{id: id, location: loc})
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return nil, err
}
var locations []string
var ids []any
for _, candidate := range candidates {
var noteRefs int
fileID := store.EncodeUid(int64(id)).String()
fileID := store.EncodeUid(int64(candidate.id)).String()
if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(&noteRefs); err != nil {
break
}
if noteRefs > 0 {
continue
}
if loc != "" {
locations = append(locations, loc)
if candidate.location != "" {
locations = append(locations, candidate.location)
}
ids = append(ids, id)
}
if err == nil {
err = rows.Err()
ids = append(ids, candidate.id)
}
if err != nil {
@@ -4903,7 +4914,24 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
defer cancel()
}
var allowed int
err := a.db.GetContext(ctx, &allowed, `
buildQuery := func(includeRecorder bool) (string, []any) {
recorderClause := ""
if includeRecorder {
recorderClause = `
OR mtg.recorder_user_id=?`
}
args := []any{
store.DecodeUid(fileID), fileID.String32(), "%/" + fileID.String32(), t.UploadCompleted,
store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend,
store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid),
store.DecodeUid(uid), fid, fid,
fid, t.MeetingStateCanceled, store.DecodeUid(uid),
}
if includeRecorder {
args = append(args, store.DecodeUid(uid))
}
args = append(args, store.DecodeUid(uid))
return `
SELECT COUNT(*) FROM im_fileuploads AS fu
WHERE (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND (
fu.userid=?
@@ -4961,18 +4989,29 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
AND mtg.end_at<=CURRENT_TIMESTAMP(3)
AND (
mtg.organizer_id=?
OR mtg.recorder_user_id=?
` + recorderClause + `
OR EXISTS (
SELECT 1 FROM im_meeting_participants AS mp
WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=?
)
)
)
) LIMIT 1`,
store.DecodeUid(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend,
store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid),
store.DecodeUid(uid), fid, fid,
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
) LIMIT 1`, args
}
includeRecorder := true
run := func() error {
query, args := buildQuery(includeRecorder)
return a.db.GetContext(ctx, &allowed, query, args...)
}
err := run()
if isUnknownColumn(err) && strings.Contains(strings.ToLower(err.Error()), "recorder_user_id") {
includeRecorder = false
err = run()
}
for attempt := 0; isRetryableMySQLReadErr(err) && attempt < 2; attempt++ {
time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond)
err = run()
}
if err != nil {
return false, err
}
@@ -5111,6 +5150,20 @@ func isIDOutOfRange(err error) bool {
return ok && myerr.Number == 1264 && strings.Contains(strings.ToLower(myerr.Message), "id")
}
func isRetryableMySQLReadErr(err error) bool {
if err == nil {
return false
}
var myerr *ms.MySQLError
if !errors.As(err, &myerr) {
return false
}
if myerr.Number == 1213 || myerr.Number == 1205 {
return true
}
return strings.EqualFold(string(myerr.SQLState[:]), "40001")
}
func isPXCStrictModeNamedLockError(err error) bool {
if err == nil {
return false
+208
View File
@@ -0,0 +1,208 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
const legacyGroupMessageTable = "im_group_messages"
func ensureGroupMessageShardInfrastructure(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_message_shards (
shard_month CHAR(6) NOT NULL, table_name VARCHAR(64) NOT NULL,
shard_start DATETIME(3) NOT NULL, shard_end DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'prepared', row_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3), max_created_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(shard_month), UNIQUE INDEX uk_im_group_message_shards_table(table_name),
INDEX idx_im_group_message_shards_status(status, shard_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
topic VARCHAR(64) NOT NULL, shard_month CHAR(6) NOT NULL,
min_seq BIGINT NOT NULL, max_seq BIGINT NOT NULL, message_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NOT NULL, max_created_at DATETIME(3) NOT NULL,
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(topic, shard_month), INDEX idx_im_group_message_topic_shards_seq(topic, max_seq),
INDEX idx_im_group_message_topic_shards_month(shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_message_dedup (
topic VARCHAR(64) NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL, shard_month CHAR(6) NOT NULL, message_id BIGINT,
status VARCHAR(16) NOT NULL DEFAULT 'pending', expires_at DATETIME(3) NOT NULL,
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(topic, from_user, client_msg_id), INDEX idx_im_group_message_dedup_expires(expires_at),
INDEX idx_im_group_message_dedup_topic_seq(topic, seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return ensureGroupMessageShardWindow(exec, time.Now().UTC())
}
func ensureGroupMessageShardWindow(exec sqlExecutor, now time.Time) error {
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
for _, offset := range []int{-1, 0, 1, 2} {
start := first.AddDate(0, offset, 0)
status := "prepared"
if offset == -1 {
status = "sealing"
} else if offset == 0 {
status = "writable"
}
if err := ensureGroupMessageShard(exec, start, status); err != nil {
return err
}
}
_, err := exec.Exec("UPDATE im_group_message_shards SET status='sealed' WHERE status='sealing' AND shard_end<?", first.AddDate(0, 0, -7))
return err
}
func (a *adapter) ensureCurrentGroupMessageShardWindow() error {
month := time.Now().UTC().Format("200601")
a.tenantMu.Lock()
defer a.tenantMu.Unlock()
if a.groupShardWindow == month {
return nil
}
if err := ensureGroupMessageShardWindow(a.db, time.Now().UTC()); err != nil {
return err
}
a.groupShardWindow = month
return nil
}
func ensureGroupMessageShard(exec sqlExecutor, start time.Time, status string) error {
month := start.UTC().Format("200601")
table, ok := groupMessageShardTable(month)
if !ok {
return fmt.Errorf("mysql: invalid group message shard month %q", month)
}
ddl := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
message_id BIGINT NOT NULL, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(message_id), UNIQUE INDEX uk_%s_group_seq(group_id, seqid),
UNIQUE INDEX uk_%s_topic_seq(topic, seqid),
UNIQUE INDEX uk_%s_sender_client(topic, from_user, client_msg_id),
INDEX idx_%s_group_status_seq(group_id, status, seqid),
INDEX idx_%s_topic_created(topic, created_at), INDEX idx_%s_topic_from_seq(topic, from_user, seqid),
INDEX idx_%s_audit_created(audit_status, created_at), INDEX idx_%s_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
table, month, month, month, month, month, month, month, month)
if _, err := exec.Exec(ddl); err != nil {
return err
}
end := start.AddDate(0, 1, 0)
_, err := exec.Exec(`INSERT INTO im_group_message_shards
(shard_month,table_name,shard_start,shard_end,status)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE table_name=VALUES(table_name),
shard_start=VALUES(shard_start),shard_end=VALUES(shard_end),
status=IF(status IN ('sealed','archived'),status,VALUES(status))`,
month, table, start, end, status)
return err
}
func groupMessageShardTable(month string) (string, bool) {
if len(month) != 6 {
return "", false
}
for i := range month {
if month[i] < '0' || month[i] > '9' {
return "", false
}
}
parsed, err := time.Parse("200601", month)
if err != nil || parsed.Format("200601") != month {
return "", false
}
return "im_group_messages_" + month, true
}
func groupMessageTablesForTopic(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string) ([]string, error) {
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
redisAvailable := err == nil
if err != nil {
cached = false
}
if !cached {
routes = nil
if err = sqlx.SelectContext(ctx, q, &routes,
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
FROM im_group_message_topic_shards WHERE topic=? ORDER BY shard_month DESC`, topic); err != nil {
return nil, err
}
if redisAvailable {
for _, route := range routes {
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
}
}
}
tables := make([]string, 0, len(routes)+1)
for _, route := range routes {
if table, ok := groupMessageShardTable(route.Month); ok {
tables = append(tables, table)
}
}
return append(tables, legacyGroupMessageTable), nil
}
func groupMessageTablesForSeq(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64) ([]string, error) {
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
redisAvailable := err == nil
if err != nil {
cached = false
}
if !cached {
routes = nil
if err = sqlx.SelectContext(ctx, q, &routes,
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
FROM im_group_message_topic_shards WHERE topic=? AND min_seq<=? AND max_seq>=?
ORDER BY shard_month DESC`, topic, seq, seq); err != nil {
return nil, err
}
if redisAvailable {
for _, route := range routes {
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
}
}
}
tables := make([]string, 0, 2)
for _, route := range routes {
if route.MinSeq <= seq && route.MaxSeq >= seq {
if table, ok := groupMessageShardTable(route.Month); ok {
tables = append(tables, table)
}
}
}
return append(tables, legacyGroupMessageTable), nil
}
func groupMessageMonthFromTable(table string) string {
const prefix = "im_group_messages_"
if len(table) == len(prefix)+6 && table[:len(prefix)] == prefix {
return table[len(prefix):]
}
return ""
}
+1 -1
View File
@@ -167,5 +167,5 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
return err
}
}
return nil
return ensureGroupMessageShardInfrastructure(exec)
}
+18 -2
View File
@@ -22,8 +22,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 13 {
t.Fatalf("executed %d group schema statements, want 13", len(exec.statements))
if len(exec.statements) != 25 {
t.Fatalf("executed %d group schema statements, want 25", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
@@ -46,4 +46,20 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if !strings.Contains(joined, "json_set(public, '$.fn'") {
t.Fatal("group schema is missing historical public.fn backfill")
}
for _, expected := range []string{"im_group_message_shards", "im_group_message_topic_shards", "im_group_message_dedup", "primary key(message_id)"} {
if !strings.Contains(joined, expected) {
t.Fatalf("group shard schema is missing %q", expected)
}
}
}
func TestGroupMessageShardTableRejectsUnsafeMonth(t *testing.T) {
if table, ok := groupMessageShardTable("202609"); !ok || table != "im_group_messages_202609" {
t.Fatalf("unexpected valid shard: table=%q ok=%v", table, ok)
}
for _, month := range []string{"", "20269", "202613", "2026_09", "202609;DROP TABLE im_users"} {
if table, ok := groupMessageShardTable(month); ok || table != "" {
t.Fatalf("unsafe shard month accepted: month=%q table=%q", month, table)
}
}
}
+511 -140
View File
@@ -172,6 +172,15 @@ func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any {
return data
}
func ensurePrivateMutationVersion(private any, version int64) any {
data, _ := private.(map[string]any)
if data == nil {
data = map[string]any{}
}
data["mutation_version"] = version
return data
}
func clearProfilePublicFields(public map[string]any) {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
delete(public, key)
@@ -261,6 +270,9 @@ func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt6
func normalizeGroupMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
msg.TenantID = id
if rawID, err := strconv.ParseInt(msg.Id, 10, 64); err == nil && rawID > 0 {
msg.SetUid(store.EncodeUid(rawID))
}
msg.From = common.EncodeUidString(msg.From).String()
if recalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
@@ -309,6 +321,17 @@ func messageHeadBool(head t.KVMap, key string) bool {
}
}
func (a *adapter) tenantTopicBumpMutationVersionTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, topic string, at time.Time) error {
if a.tenantScopedDB {
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE name=?",
at, at, topic)
return err
}
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE tenant_id=? AND name=?",
at, at, id, topic)
return err
}
func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef {
if file != nil {
file.TenantID = id
@@ -888,7 +911,7 @@ func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error)
}
var top t.Topic
err = a.db.GetContext(ctx, &top,
"SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
"SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted,tags,aux "+
"FROM im_topics WHERE tenant_id=? AND name=?", id, topic)
if err == sql.ErrNoRows {
return nil, nil
@@ -1013,7 +1036,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
return nil, err
}
if len(topq) > 0 {
q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)"
q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)"
q, args, _ = sqlx.In(q, id, topq)
if !keepDeleted {
q += " AND state!=?"
@@ -1042,6 +1065,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
sub.SetState(top.State)
sub.SetTouchedAt(top.TouchedAt)
sub.SetSeqId(top.SeqId)
sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion)
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
sub.SetSubCnt(top.SubCnt)
sub.SetPublic(common.FromJSON(top.Public))
@@ -1369,44 +1393,70 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD
return nil
}
func groupMessageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
func groupMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDel *t.DelMessage) error {
var err error
ctx := context.Background()
tables, err := groupMessageTablesForTopic(tx, ctx, tenantID, topic)
if err != nil {
return err
}
if toDel == nil {
if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil {
return err
}
_, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic)
for _, table := range tables {
if _, err = tx.Exec("DELETE FROM "+table+" WHERE topic=?", topic); err != nil {
return err
}
}
if _, err = tx.Exec("DELETE FROM im_group_message_topic_shards WHERE topic=?", topic); err != nil {
return err
}
_, err = tx.Exec("DELETE FROM im_group_message_dedup WHERE topic=?", topic)
return err
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
where := "m.topic=?"
args := []any{topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deleted_at IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.created_at>?"
args = append(args, newerThan)
}
var seqIDs []int64
if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil {
return err
seqIDs := make([]int64, 0)
for _, table := range tables {
where := "m.topic=?"
args := []any{topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deleted_at IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.created_at>?"
args = append(args, newerThan)
}
var tableSeqIDs []int64
if err = tx.Select(&tableSeqIDs, "SELECT seqid FROM "+table+" AS m WHERE "+where, args...); err != nil {
return err
}
seqIDs = append(seqIDs, tableSeqIDs...)
}
if len(seqIDs) == 0 {
return nil
}
sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
delRanges = t.SliceToRanges(seqIDs)
uniqueSeqIDs := seqIDs[:0]
for _, seqID := range seqIDs {
if len(uniqueSeqIDs) == 0 || uniqueSeqIDs[len(uniqueSeqIDs)-1] != seqID {
uniqueSeqIDs = append(uniqueSeqIDs, seqID)
}
}
delRanges = t.SliceToRanges(uniqueSeqIDs)
rSQL, rArgs := common.RangesToSql(delRanges)
where = "m.topic=? AND m.seqid " + rSQL
args = append([]any{topic}, rArgs...)
if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil {
return err
where := "m.topic=? AND m.seqid " + rSQL
args := append([]any{topic}, rArgs...)
now := t.TimeNow()
for _, table := range tables {
if _, err = tx.Exec("UPDATE "+table+" AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
where, append([]any{now, now}, args...)...); err != nil {
return err
}
}
}
stmt, err := tx.Prepare("INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)")
@@ -1460,7 +1510,7 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo
return err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
err = groupMessageDeleteList(tx, topic, nil)
err = groupMessageDeleteList(tx, id, topic, nil)
} else {
err = tenantMessageDeleteList(tx, id, topic, nil)
}
@@ -1575,6 +1625,49 @@ func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[stri
return tx.Commit()
}
func (a *adapter) TenantTopicBumpMutationVersion(id t.TenantID, topic string, at time.Time) (int64, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return 0, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicBumpMutationVersion(id, topic, at)
}
if at.IsZero() {
at = time.Now().UTC().Round(time.Millisecond)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, at); err != nil {
return 0, err
}
var version int64
query := "SELECT mutation_version FROM im_topics WHERE name=?"
args := []any{topic}
if !a.tenantScopedDB {
query = "SELECT mutation_version FROM im_topics WHERE tenant_id=? AND name=?"
args = []any{id, topic}
}
if err = tx.GetContext(ctx, &version, query, args...); err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
@@ -1989,6 +2082,9 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
if tenantAdp != a {
return tenantAdp.TenantGroupMessageSave(id, msg)
}
if err = a.ensureCurrentGroupMessageShardWindow(); err != nil {
return err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
@@ -1997,24 +2093,77 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
if err != nil {
return err
}
if msg.ShardMonth == "" {
msg.ShardMonth = msg.CreatedAt.UTC().Format("200601")
}
table, ok := groupMessageShardTable(msg.ShardMonth)
if !ok || msg.Uid().IsZero() {
return t.ErrMalformed
}
if msg.ShardMonth == time.Now().UTC().Format("200601") {
if _, err = a.db.ExecContext(ctx, "UPDATE im_group_message_shards SET status='writable' WHERE shard_month=? AND status='prepared'", msg.ShardMonth); err != nil {
return err
}
}
var shardStatus string
if err = a.db.QueryRowContext(ctx, "SELECT status FROM im_group_message_shards WHERE shard_month=? AND table_name=?", msg.ShardMonth, table).Scan(&shardStatus); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("mysql: group message shard %s is not registered", msg.ShardMonth)
}
return err
}
if shardStatus != "writable" && shardStatus != "sealing" {
return fmt.Errorf("mysql: group message shard %s is %s", msg.ShardMonth, shardStatus)
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
contentJSON := common.ToJSON(msg.Content)
res, err := a.db.ExecContext(ctx,
`INSERT INTO im_group_messages
(topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
messageID := store.DecodeUid(msg.Uid())
if clientMsgID.Valid {
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_dedup
(topic,from_user,client_msg_id,seqid,shard_month,message_id,status,expires_at)
VALUES(?,?,?,?,?,?,'done',?)`, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID,
msg.SeqId, msg.ShardMonth, messageID, time.Now().UTC().Add(30*24*time.Hour)); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
query := fmt.Sprintf(`INSERT INTO %s
(message_id,topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
file_count,content_size,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, table)
_, err = tx.ExecContext(ctx, query,
messageID, msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt)
if err == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_topic_shards
(topic,shard_month,min_seq,max_seq,message_count,min_created_at,max_created_at)
VALUES(?,?,?,?,1,?,?) ON DUPLICATE KEY UPDATE
min_seq=LEAST(min_seq,VALUES(min_seq)),max_seq=GREATEST(max_seq,VALUES(max_seq)),
message_count=message_count+1,min_created_at=LEAST(min_created_at,VALUES(min_created_at)),
max_created_at=GREATEST(max_created_at,VALUES(max_created_at))`,
msg.Topic, msg.ShardMonth, msg.SeqId, msg.SeqId, msg.CreatedAt, msg.CreatedAt); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_message_shards SET row_count=row_count+1,
min_created_at=IF(min_created_at IS NULL,?,LEAST(min_created_at,?)),
max_created_at=IF(max_created_at IS NULL,?,GREATEST(max_created_at,?)) WHERE shard_month=?`,
msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, msg.ShardMonth); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
@@ -2067,22 +2216,44 @@ func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, f
if cancel != nil {
defer cancel()
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err = a.db.QueryRowxContext(ctx,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?",
topic, decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
return nil, nil
var seq int64
if err = a.db.QueryRowContext(ctx, `SELECT seqid FROM im_group_message_dedup
WHERE topic=? AND from_user=? AND client_msg_id=?`, topic, decodeUID(from), clientMsgID).Scan(&seq); err == nil {
msg, getErr := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
if getErr == nil {
return msg, nil
}
if getErr != sql.ErrNoRows {
return nil, getErr
}
} else if err != sql.ErrNoRows {
return nil, err
}
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
return &msg, nil
for _, table := range tables {
month := groupMessageMonthFromTable(table)
idColumn := "id"
if month != "" {
idColumn = "message_id"
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,'%s' AS shard_month FROM %s WHERE topic=? AND from_user=? AND client_msg_id=?", idColumn, month, table)
err = a.db.QueryRowxContext(ctx, query, topic, decodeUID(from), clientMsgID).StructScan(&row)
if err == nil {
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
return &msg, nil
}
if err != sql.ErrNoRows {
return nil, err
}
}
return nil, nil
}
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
@@ -2173,6 +2344,9 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, ac
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
@@ -2203,7 +2377,7 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
msg, messageTable, err := tenantGroupMessageGetOneWithTable(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
@@ -2213,12 +2387,15 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?",
_, err = tx.ExecContext(ctx, fmt.Sprintf(
"UPDATE %s SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", messageTable),
recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
@@ -2272,6 +2449,9 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, acto
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
@@ -2302,7 +2482,7 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
msg, messageTable, err := tenantGroupMessageGetOneWithTable(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
@@ -2318,12 +2498,15 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
return nil, err
}
contentJSON := common.ToJSON(content)
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?",
_, err = tx.ExecContext(ctx, fmt.Sprintf(
"UPDATE %s SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?", messageTable),
editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
@@ -2407,58 +2590,84 @@ func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Ui
return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts)
}
limit := a.maxMessageResults
args := []any{store.DecodeUid(uid), topic}
seq := ""
seqClause := ""
seqArgs := make([]any, 0)
if opts != nil {
seq = "AND m.seqid "
seqClause = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seq += constr
args = append(args, addArgs...)
seqClause += constr
seqArgs = append(seqArgs, addArgs...)
} else {
seq += "BETWEEN ? AND ?"
seqClause += "BETWEEN ? AND ?"
if opts.Since > 0 {
args = append(args, opts.Since)
seqArgs = append(seqArgs, opts.Since)
} else {
args = append(args, 0)
seqArgs = append(seqArgs, 0)
}
if opts.Before > 1 {
args = append(args, opts.Before-1)
seqArgs = append(seqArgs, opts.Before-1)
} else {
args = append(args, 1<<31-1)
seqArgs = append(seqArgs, int64(^uint64(0)>>1))
}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
"ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
args...)
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil {
return nil, err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
seen := make(map[int64]struct{}, limit)
for _, table := range tables {
if len(msgs) >= limit {
break
}
month := groupMessageMonthFromTable(table)
idColumn := "id"
if month != "" {
idColumn = "message_id"
}
args := []any{store.DecodeUid(uid), topic}
args = append(args, seqArgs...)
args = append(args, limit-len(msgs))
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.status!='deleted' AND m.topic=? %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, seqClause)
rows, queryErr := a.db.QueryxContext(ctx, query, args...)
if queryErr != nil {
return nil, queryErr
}
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
rows.Close()
return nil, err
}
if _, exists := seen[row.SeqId]; exists {
continue
}
seen[row.SeqId] = struct{}{}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
}
if err = rows.Err(); err != nil {
rows.Close()
return nil, err
}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
rows.Close()
}
return msgs, rows.Err()
sort.Slice(msgs, func(i, j int) bool { return msgs[i].SeqId > msgs[j].SeqId })
if len(msgs) > limit {
msgs = msgs[:limit]
}
return msgs, nil
}
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
@@ -2543,7 +2752,7 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
where := []string{"m.status!='deleted'", "m.topic=?"}
args := []any{topic}
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
@@ -2564,43 +2773,182 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
if cancel != nil {
defer cancel()
}
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
queryArgs = append(queryArgs, limit+1)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
"ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
queryArgs...)
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil {
return nil, "", err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
nextCursor := ""
lastSeq := int64(0)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
msgs := make([]t.Message, 0, limit+1)
seen := make(map[int64]struct{}, limit+1)
for _, table := range tables {
if len(msgs) > limit {
break
}
month := groupMessageMonthFromTable(table)
idColumn := "id"
if month != "" {
idColumn = "message_id"
}
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
queryArgs = append(queryArgs, limit+1-len(msgs))
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
if queryErr != nil {
return nil, "", queryErr
}
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
rows.Close()
return nil, "", err
}
if _, exists := seen[row.SeqId]; exists {
continue
}
seen[row.SeqId] = struct{}{}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
}
if err = rows.Err(); err != nil {
rows.Close()
return nil, "", err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(lastSeq, 10)
continue
}
msgs = append(msgs, msg)
lastSeq = msg.SeqId
rows.Close()
}
if err = rows.Err(); err != nil {
return nil, "", err
sort.Slice(msgs, func(i, j int) bool { return msgs[i].SeqId > msgs[j].SeqId })
nextCursor := ""
if len(msgs) > limit {
msgs = msgs[:limit]
lastSeq := msgs[len(msgs)-1].SeqId
nextCursor = strconv.FormatInt(lastSeq, 10)
}
return msgs, nextCursor, nil
}
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", false, 0, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageMutations(id, topic, uid, sinceVersion, limit)
}
}
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() || sinceVersion < 0 {
return nil, "", false, 0, t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var currentVersion int64
if a.tenantScopedDB {
if err := a.db.GetContext(ctx, &currentVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE name=?", topic); err != nil {
if err == sql.ErrNoRows {
return nil, "", false, 0, t.ErrNotFound
}
return nil, "", false, 0, err
}
} else {
if err := a.db.GetContext(ctx, &currentVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
if err == sql.ErrNoRows {
return nil, "", false, 0, t.ErrNotFound
}
return nil, "", false, 0, err
}
}
if sinceVersion >= currentVersion {
return []t.MessageMutation{}, strconv.FormatInt(currentVersion, 10), false, currentVersion, nil
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
return a.tenantGroupMessageMutations(ctx, id, topic, uid, limit, currentVersion)
}
return a.tenantP2PMessageMutations(ctx, id, topic, uid, limit, currentVersion)
}
func (a *adapter) tenantP2PMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
rows, err := a.db.QueryxContext(ctx,
"SELECT m.seqid,m.version,m.updatedat AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
"FROM im_messages AS m "+
"LEFT JOIN im_dellog AS d ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"LEFT JOIN im_message_revisions AS rev ON rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
"WHERE m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deletedat IS NOT NULL) "+
"ORDER BY m.updatedat DESC,m.seqid DESC LIMIT ?",
decodeUID(uid), topic, limit+1)
if err != nil {
return nil, "", false, 0, err
}
defer rows.Close()
return scanTenantMessageMutations(rows, limit, currentVersion)
}
func (a *adapter) tenantGroupMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
rows, err := a.db.QueryxContext(ctx,
"SELECT m.seqid,m.version,m.updated_at AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
"FROM im_group_messages AS m "+
"LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"LEFT JOIN im_message_revisions AS rev ON rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
"WHERE m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deleted_at IS NOT NULL OR m.status='recalled') "+
"ORDER BY m.updated_at DESC,m.seqid DESC LIMIT ?",
decodeUID(uid), topic, limit+1)
if err != nil {
return nil, "", false, 0, err
}
defer rows.Close()
return scanTenantMessageMutations(rows, limit, currentVersion)
}
func scanTenantMessageMutations(rows *sqlx.Rows, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
items := make([]t.MessageMutation, 0, limit)
hasMore := false
for rows.Next() {
row := struct {
SeqId int64 `db:"seqid"`
Version int `db:"version"`
MutatedAt time.Time `db:"mutated_at"`
RecalledAt sql.NullTime `db:"recalled_at"`
RecalledBy sql.NullInt64 `db:"recalled_by"`
Actor sql.NullInt64 `db:"actor"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, "", false, 0, err
}
if len(items) == limit {
hasMore = true
continue
}
event := "message.edited"
operator := ""
if row.Actor.Valid {
operator = store.EncodeUid(row.Actor.Int64).String()
}
if row.RecalledAt.Valid {
event = "message.recalled"
if row.RecalledBy.Valid {
operator = store.EncodeUid(row.RecalledBy.Int64).String()
}
row.MutatedAt = row.RecalledAt.Time
}
items = append(items, t.MessageMutation{
SeqId: row.SeqId,
Version: row.Version,
Event: event,
Operator: operator,
MutatedAt: row.MutatedAt,
MutationVersion: currentVersion,
})
}
if err := rows.Err(); err != nil {
return nil, "", false, 0, err
}
return items, strconv.FormatInt(currentVersion, 10), hasMore, currentVersion, nil
}
func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -6261,6 +6609,10 @@ func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.User
sets = append(sets, "read_receipts=?")
args = append(args, *patch.ReadReceipts)
}
if patch.FriendRequestVerification != nil {
sets = append(sets, "friend_request_verification=?")
args = append(args, *patch.FriendRequestVerification)
}
if patch.GroupInviteVerification != nil {
sets = append(sets, "group_invite_verification=?")
args = append(args, *patch.GroupInviteVerification)
@@ -6832,30 +7184,32 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi
func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPrivacySettings, error) {
row := struct {
UserID int64 `db:"user_id"`
AddMeMethods []byte `db:"add_me_methods"`
ProfileVisibility string `db:"profile_visibility"`
LastSeenVisibility string `db:"last_seen_visibility"`
ReadReceipts bool `db:"read_receipts"`
GroupInviteVerification bool `db:"group_invite_verification"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserID int64 `db:"user_id"`
AddMeMethods []byte `db:"add_me_methods"`
ProfileVisibility string `db:"profile_visibility"`
LastSeenVisibility string `db:"last_seen_visibility"`
ReadReceipts bool `db:"read_receipts"`
FriendRequestVerification bool `db:"friend_request_verification"`
GroupInviteVerification bool `db:"group_invite_verification"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility,
read_receipts,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil {
read_receipts,friend_request_verification,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil {
return nil, err
}
settings := &t.UserPrivacySettings{
UserID: store.EncodeUid(row.UserID),
AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
ProfileVisibility: row.ProfileVisibility,
LastSeenVisibility: row.LastSeenVisibility,
ReadReceipts: row.ReadReceipts,
GroupInviteVerification: row.GroupInviteVerification,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
UserID: store.EncodeUid(row.UserID),
AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
ProfileVisibility: row.ProfileVisibility,
LastSeenVisibility: row.LastSeenVisibility,
ReadReceipts: row.ReadReceipts,
FriendRequestVerification: row.FriendRequestVerification,
GroupInviteVerification: row.GroupInviteVerification,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods)
return settings, nil
@@ -6902,19 +7256,36 @@ func (a *adapter) groupIDByTopic(ctx context.Context, topic string) (int64, erro
}
func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := sqlx.GetContext(ctx, q, &row,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND seqid=?"+suffix,
topic, seq)
msg, _, err := tenantGroupMessageGetOneWithTable(q, ctx, tenantID, topic, seq, suffix)
return msg, err
}
func tenantGroupMessageGetOneWithTable(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, string, error) {
tables, err := groupMessageTablesForSeq(q, ctx, tenantID, topic, seq)
if err != nil {
return nil, err
return nil, "", err
}
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
return &msg, nil
for _, table := range tables {
month := groupMessageMonthFromTable(table)
idColumn := "id"
if month != "" {
idColumn = "message_id"
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,'%s' AS shard_month FROM %s WHERE topic=? AND seqid=?%s", idColumn, month, table, suffix)
err = sqlx.GetContext(ctx, q, &row, query, topic, seq)
if err == nil {
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
return &msg, table, nil
}
if err != sql.ErrNoRows {
return nil, "", err
}
}
return nil, "", sql.ErrNoRows
}
func messageVersion(version int) int {
@@ -7395,7 +7766,7 @@ func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del
tx.Rollback()
}
}()
if err = groupMessageDeleteList(tx, topic, del); err != nil {
if err = groupMessageDeleteList(tx, id, topic, del); err != nil {
return err
}
return tx.Commit()
@@ -8949,7 +9320,7 @@ func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eve
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_system_notifications
SET read_at=COALESCE(read_at,?),updated_at=?
SET read_at=?,updated_at=?
WHERE user_id=? AND event_id=?`,
readAt, readAt, decodeUID(uid), eventID)
if err != nil {
+14 -1
View File
@@ -4,7 +4,10 @@
package mysql
import (
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jmoiron/sqlx"
@@ -60,7 +63,17 @@ func (a *adapter) TenantApplicationCreate(application *t.TenantApplication) (*t.
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_applications
var existingID string
err := db.QueryRowContext(ctx, `SELECT application_id FROM im_tenant_applications
WHERE credit_code=? AND status IN ('received','pending','reviewing') ORDER BY id DESC LIMIT 1`,
strings.ToUpper(strings.TrimSpace(application.CreditCode))).Scan(&existingID)
if err == nil && existingID != "" {
return nil, t.ErrDuplicate
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
_, err = db.ExecContext(ctx, `INSERT INTO im_tenant_applications
(application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
application.ApplicationID, application.CreditCode, application.EnterpriseEmail, application.LicenseFileID,
+22 -2
View File
@@ -20,8 +20,10 @@ const (
groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,
member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,
created_by,dissolved_by,dissolved_at,created_at,updated_at`
groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at,
(SELECT COUNT(*) FROM im_group_announcement_reads AS ar WHERE ar.topic=im_group_announcements.topic AND ar.announcement_id=im_group_announcements.announcement_id) AS read_count,
(SELECT COUNT(*) FROM im_group_members AS gm WHERE gm.topic=im_group_announcements.topic AND gm.member_status='active') AS member_count`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
)
func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) {
@@ -930,6 +932,24 @@ func (a *adapter) TenantGroupMuteList(id t.TenantID, topic, cursor string, limit
return items, next, rows.Err()
}
func (a *adapter) TenantGroupMuteGet(id t.TenantID, topic string, uid t.Uid) (*t.GroupMute, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupMuteGet(id, topic, uid)
}
if topic == "" || uid.IsZero() {
return nil, t.ErrMalformed
}
now := time.Now().UTC()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?)",
topic, decodeUID(uid), t.GroupMuteStatusActive, now)
}
func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.GroupMute, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
+42 -9
View File
@@ -36,6 +36,30 @@ func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent
return err
}
func (a *adapter) TenantContentModerationLogCreate(id t.TenantID, event *t.ContentModerationLog) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil || strings.TrimSpace(event.RequestID) == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantContentModerationLogCreate(id, event)
}
labels, err := json.Marshal(event.RiskLabels)
if err != nil {
return err
}
var actorID any
if !event.ActorID.IsZero() {
actorID = decodeUID(event.ActorID)
}
_, err = a.db.Exec(`INSERT INTO im_content_moderation_logs
(request_id,scene,topic,seqid,actor_id,provider,provider_request_id,risk_level,risk_labels,action,content_hash,latency_ms,error_code,created_at)
VALUES(?,?,?,?,?,?,?,?,CAST(? AS JSON),?,?,?,?,?)`, event.RequestID, event.Scene, event.Topic, event.SeqID, actorID,
event.Provider, event.ProviderRequestID, event.RiskLevel, string(labels), event.Action, event.ContentHash,
event.LatencyMS, event.ErrorCode, event.CreatedAt)
return err
}
func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil {
@@ -98,7 +122,7 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
return tx.Commit()
}
sender, beforeStatus, err := lockAdminMessage(ctx, tx, action.ConversationType, action.Topic, action.SeqID)
sender, beforeStatus, messageTable, err := lockAdminMessage(ctx, tx, id, action.ConversationType, action.Topic, action.SeqID)
if err != nil {
return err
}
@@ -108,7 +132,7 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
afterStatus = "deleted"
}
if action.ConversationType == "group" {
_, err = tx.ExecContext(ctx, "UPDATE im_group_messages SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
_, err = tx.ExecContext(ctx, "UPDATE "+messageTable+" SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
} else {
_, err = tx.ExecContext(ctx, "UPDATE im_messages SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
}
@@ -127,17 +151,26 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
return tx.Commit()
}
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic string, seqID int64) (int64, string, error) {
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID, conversationType, topic string, seqID int64) (int64, string, string, error) {
if conversationType == "group" {
tables, err := groupMessageTablesForSeq(tx, ctx, tenantID, topic, seqID)
if err != nil {
return 0, "", "", err
}
var row struct {
Sender int64 `db:"from_user"`
Status string `db:"status"`
}
err := tx.GetContext(ctx, &row, "SELECT from_user,status FROM im_group_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
for _, table := range tables {
err = tx.GetContext(ctx, &row, "SELECT from_user,status FROM "+table+" WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if err == nil {
return row.Sender, row.Status, table, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, "", "", err
}
}
return row.Sender, row.Status, err
return 0, "", "", t.ErrNotFound
}
var row struct {
Sender int64 `db:"from"`
@@ -146,7 +179,7 @@ func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic
}
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM im_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
return 0, "", "", t.ErrNotFound
}
status := "normal"
if row.RecalledAt.Valid {
@@ -155,7 +188,7 @@ func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic
if row.DeletedAt.Valid {
status = "deleted"
}
return row.Sender, status, err
return row.Sender, status, "im_messages", err
}
func nullableJSON(raw []byte) any {
+20
View File
@@ -150,6 +150,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil {
return err
}
if err = ensureUserAccountSequence(ctx, conn); err != nil {
return err
}
if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil {
return err
}
@@ -171,6 +174,23 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return nil
}
func ensureUserAccountSequence(ctx context.Context, conn *sqlx.Conn) error {
exists, err := identityColumnExists(ctx, conn, "im_users", "account_no")
if err != nil || !exists {
return err
}
if _, err = conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS im_user_account_sequences(
id TINYINT UNSIGNED NOT NULL,next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil {
return err
}
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_account_sequences(id,next_value)
SELECT 1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users
ON DUPLICATE KEY UPDATE next_value=GREATEST(next_value,VALUES(next_value))`)
return err
}
func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
prefix := "0 AS tenant_id,u.id AS user_id,"
join := "a.userid=u.id"
+2 -21
View File
@@ -257,7 +257,7 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
return
}
meetingID, meetingNoteIdempotencyKey, err := meetingNoteUploadParams(req, principal)
meetingID, _, err := meetingNoteUploadParams(req, principal)
if err != nil {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
@@ -375,16 +375,8 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339)
}
if meetingID != "" {
noteFile, err := createMeetingNoteFromUpload(principal, meetingID, meetingNoteIdempotencyKey, fdef)
if err != nil {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
}
params["purpose"] = fileUploadPurposeMeetingNote
params["meeting_id"] = noteFile.MeetingID
params["note_file_id"] = noteFile.NoteFileID
params["note_file_status"] = noteFile.Status
params["version"] = noteFile.Version
params["meeting_id"] = meetingID
}
writeHttpResponse(NoErrParams(msgID, "", now, params), nil)
@@ -398,9 +390,6 @@ func meetingNoteUploadParams(req *http.Request, principal RequestPrincipal) (str
if principal.UID.IsZero() {
return "", "", types.ErrPermissionDenied
}
if store.MeetingNotes == nil {
return "", "", types.ErrUnsupported
}
meetingID := strings.TrimSpace(req.FormValue("meeting_id"))
if meetingID == "" {
return "", "", types.ErrMalformed
@@ -415,14 +404,6 @@ func meetingNoteUploadParams(req *http.Request, principal RequestPrincipal) (str
return meetingID, idempotencyKey, nil
}
func createMeetingNoteFromUpload(principal RequestPrincipal, meetingID, idempotencyKey string, fdef *types.FileDef) (*types.MeetingNoteFile, error) {
if fdef == nil || fdef.Uid().IsZero() {
return nil, types.ErrMalformed
}
noteFileID := "mnote_" + store.Store.GetUidString()
return store.MeetingNotes.CreateFile(principal.TenantID, principal.UID, meetingID, noteFileID, fdef.Uid().String(), idempotencyKey)
}
// LargeFileServe is the gRPC equivalent of largeFileServeHTTP.
func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
now := types.TimeNow()
+8 -7
View File
@@ -210,7 +210,7 @@ func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
}
func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) {
func TestLargeFileReceiveMeetingNoteUploadReturnsFileID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -229,17 +229,15 @@ func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) {
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
notes := &testMeetingNotesStore{}
store.Store = mockStore
store.Files = files
store.MeetingNotes = notes
store.MeetingNotes = nil
kafkaBus = nil
fileID := types.Uid(101)
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
mockStore.EXPECT().GetUidString().Return(fileID.String())
mockStore.EXPECT().GetUidString().Return("note-1")
tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "192.0.2.1:12345"}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
files.EXPECT().FinishUpload(types.TenantID(7), gomock.Any(), true, int64(11)).DoAndReturn(
@@ -289,10 +287,13 @@ func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) {
}
params := resp.Ctrl.Params.(map[string]any)
if params["file_id"] != fileID.String() || params["purpose"] != fileUploadPurposeMeetingNote ||
params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" {
params["meeting_id"] != "mtg_test" {
t.Fatalf("unexpected upload params: %#v", params)
}
if handler.data != "hello notes" || handler.def.OriginalName != "notes.txt" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" {
t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes)
if _, ok := params["note_file_id"]; ok {
t.Fatalf("upload should not bind meeting note file: %#v", params)
}
if handler.data != "hello notes" || handler.def.OriginalName != "notes.txt" {
t.Fatalf("unexpected side effects: handler=%#v", handler)
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ const (
pongWait = idleSessionTimeout
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = 30 * time.Second
pingPeriod = 15 * time.Second
)
func (sess *Session) closeWS() {
+952 -105
View File
File diff suppressed because it is too large Load Diff
+567 -14
View File
@@ -176,6 +176,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
type testRelationsStore struct {
context *types.RelationshipContext
err error
created *types.FriendRequest
blocked *types.UserBlock
deleted bool
@@ -547,6 +548,15 @@ func (s *testGroupBusinessStore) ListMutes(_ types.TenantID, _ string, _ string,
return s.mutes, "", nil
}
func (s *testGroupBusinessStore) GetMute(_ types.TenantID, _ string, uid types.Uid) (*types.GroupMute, error) {
for i := range s.mutes {
if s.mutes[i].UserID == uid && s.mutes[i].Status == types.GroupMuteStatusActive {
return &s.mutes[i], nil
}
}
return nil, types.ErrNotFound
}
func (s *testGroupBusinessStore) UpsertMute(_ types.TenantID, mute *types.GroupMute) (*types.GroupMute, error) {
mute.Version = 1
s.mutes = append(s.mutes, *mute)
@@ -1080,7 +1090,7 @@ func (s *testProfilesStore) GetPrivacy(_ types.TenantID, uid types.Uid) (*types.
now := time.Now().UTC().Round(time.Millisecond)
s.privacy = &types.UserPrivacySettings{
UserID: uid, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now,
ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, FriendRequestVerification: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now,
}
}
return s.privacy, nil
@@ -1100,6 +1110,9 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
if patch.ReadReceipts != nil {
privacy.ReadReceipts = *patch.ReadReceipts
}
if patch.FriendRequestVerification != nil {
privacy.FriendRequestVerification = *patch.FriendRequestVerification
}
if patch.GroupInviteVerification != nil {
privacy.GroupInviteVerification = *patch.GroupInviteVerification
}
@@ -1348,6 +1361,9 @@ func (s *testRelationsStore) ListFriends(_ types.TenantID, _ types.Uid, _, _, _
}
func (s *testRelationsStore) GetRelationshipContext(_ types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) {
if s.err != nil {
return nil, s.err
}
if s.context != nil {
return s.context, nil
}
@@ -2286,6 +2302,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
apiKey := makeTestAPIKey(t)
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
@@ -2311,7 +2328,7 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -2330,6 +2347,45 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
}
}
func TestServeIMVerificationCodeVerifyAcceptsRegistrationInvite(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldInvites := store.RegistrationInvites
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive}}
verifications := &testVerificationStore{get: &types.VerificationCode{
TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash",
State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
store.Verifications = verifications
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().UTC().Add(time.Hour),
}}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
store.RegistrationInvites = oldInvites
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"AbCdE"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodeVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["verification_mode"] != "invite_code" || data["invite_accepted"] != true {
t.Fatalf("unexpected invite response: %#v", data)
}
if verifications.verified {
t.Fatal("invite code must not mark the phone challenge as SMS verified")
}
}
func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
@@ -3764,10 +3820,42 @@ func TestServeIMMailboxSubmissionMapsPolicyToValidationFailed(t *testing.T) {
}
}
func TestServeIMMeIncludesUsername(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Username: "gcharles7", Phone: "+8613800138000"}, nil)
store.Users = users
defer func() { store.Users = oldUsers }()
oldProfiles := store.Profiles
store.Profiles = &testProfilesStore{}
defer func() { store.Profiles = oldProfiles }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/me", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMe(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["user_id"] != types.Uid(42).UserId() || data["username"] != "gcharles7" {
t.Fatalf("unexpected me payload: %#v body=%s", data, rec.Body.String())
}
}
func TestServeIMMeProfile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Phone: "+8613800138000", Username: "gcharles7"}, nil)
store.Users = users
defer func() { store.Users = oldUsers }()
oldProfiles := store.Profiles
profiles := &testProfilesStore{}
store.Profiles = profiles
@@ -3782,11 +3870,57 @@ func TestServeIMMeProfile(t *testing.T) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 {
if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 ||
data["phone_mask"] != "138****8000" || data["username"] != "gcharles7" {
t.Fatalf("unexpected profile: %#v", data)
}
}
func TestServeIMMeProfileGetPhoneMask(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Phone: "+8613600003333", Username: "gcharles7"}, nil)
store.Users = users
defer func() { store.Users = oldUsers }()
oldProfiles := store.Profiles
store.Profiles = &testProfilesStore{}
defer func() { store.Profiles = oldProfiles }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/me/profile", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMeProfile(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["phone_mask"] != "136****3333" || data["username"] != "gcharles7" {
t.Fatalf("unexpected account fields: %#v body=%s", data, rec.Body.String())
}
}
func TestMaskIMProfilePhone(t *testing.T) {
for _, tc := range []struct {
name string
phone string
want string
}{
{name: "empty", phone: "", want: ""},
{name: "mainland e164", phone: "+8613600003333", want: "136****3333"},
{name: "mainland local", phone: "13600003333", want: "136****3333"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := maskIMProfilePhone(tc.phone); got != tc.want {
t.Fatalf("maskIMProfilePhone(%q)=%q, want %q", tc.phone, got, tc.want)
}
})
}
}
func TestServeIMMePrivacy(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -3796,7 +3930,7 @@ func TestServeIMMePrivacy(t *testing.T) {
store.Profiles = profiles
defer func() { store.Profiles = oldProfiles }()
req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":1}`))
req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMePrivacy(rec, req)
@@ -3807,8 +3941,8 @@ func TestServeIMMePrivacy(t *testing.T) {
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
methods := data["add_me_methods"].(map[string]any)
editable := data["editable"].(map[string]any)
if methods["phone"] != false || data["read_receipts"] != false || data["group_invite_verification"] != true ||
editable["group_invite_verification"] != true || data["version"].(float64) != 2 {
if methods["phone"] != false || data["read_receipts"] != false || data["friend_request_verification"] != false || data["group_invite_verification"] != true ||
editable["friend_request_verification"] != true || editable["group_invite_verification"] != true || data["version"].(float64) != 2 {
t.Fatalf("unexpected privacy: %#v", data)
}
}
@@ -4470,7 +4604,7 @@ func TestServeIMGroupList(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 3 || items[0].(map[string]any)["my_role"] != "owner" {
if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 0 || items[0].(map[string]any)["my_role"] != "owner" {
t.Fatalf("unexpected group list response: %#v", data)
}
}
@@ -5366,6 +5500,74 @@ func TestServeIMGroupQRCodesAndResolve(t *testing.T) {
}
}
func TestServeIMGroupInvitationDirectJoinWritesSystemNotice(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().IsOpen().Return(true).AnyTimes()
oldSubs := store.Subs
oldTopics := store.Topics
oldMessages := store.Messages
oldProfiles := store.Profiles
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
store.Messages = messages
store.Profiles = nil
store.GroupBusiness = &testGroupBusinessStore{}
t.Cleanup(func() {
store.Subs = oldSubs
store.Topics = oldTopics
store.Messages = oldMessages
store.Profiles = oldProfiles
store.GroupBusiness = oldGroupBusiness
})
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(nil, types.ErrNotFound)
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{TenantID: 7, SeqId: 7}, nil)
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), nil, false).DoAndReturn(
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
if msg.Topic != "grpAlpha" || msg.SeqId != 8 {
t.Fatalf("unexpected system notice message: %#v", msg)
}
if msg.Head["x-im-template"] != "group_member_joined" || msg.Head["x-im-type"] != "system_action" {
t.Fatalf("unexpected system notice head: %#v", msg.Head)
}
content := msg.Content.(map[string]any)
if content["text"] != "有成员通过邀请加入了群聊。" || content["template"] != "group_member_joined" {
t.Fatalf("unexpected system notice content: %#v", content)
}
extra := content["extra"].(map[string]any)
if extra["source"] != "direct_invitation" {
t.Fatalf("unexpected system notice extra: %#v", extra)
}
return &types.MessageSaveResult{SeqId: msg.SeqId}, nil
})
body := fmt.Sprintf(`{"user_ids":["%s"],"message":"欢迎"}`, types.Uid(43).UserId())
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "invite-1")
req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true))
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("invite status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["status"] != types.GroupInvitationStatusJoined {
t.Fatalf("unexpected invitation response: %#v", data)
}
}
func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5621,6 +5823,56 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
}
}
func TestServeIMGroupMutesMemberGetsOwnMuteOnly(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
store.Store = mockStore
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Subs = subs
memberSub := &types.Subscription{TenantID: 7, User: types.Uid(43).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(memberSub, nil).Times(2)
groupStore := &testGroupBusinessStore{mutes: []types.GroupMute{
{Topic: "grpAlpha", UserID: types.Uid(42), MutedBy: types.Uid(99), MutedAt: time.Now().UTC(), Status: types.GroupMuteStatusActive, Version: 1},
{Topic: "grpAlpha", UserID: types.Uid(43), MutedBy: types.Uid(99), MutedAt: time.Now().UTC(), Reason: "test", Status: types.GroupMuteStatusActive, Version: 2},
}}
store.GroupBusiness = groupStore
defer func() {
store.Store = oldStore
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
getReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/mutes", nil)
getReq.Header.Set("Authorization", "Bearer token-1")
getRec := httptest.NewRecorder()
serveIMGroupResource(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("member mute get status: got %d body=%s", getRec.Code, getRec.Body.String())
}
data := decodeIMHTTPResponse(t, getRec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["user_id"] != types.Uid(43).UserId() {
t.Fatalf("member should only see own mute item: %#v", items)
}
postReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes/batch", bytes.NewBufferString(`{"user_ids":["`+types.Uid(42).UserId()+`"]}`))
postReq.Header.Set("Authorization", "Bearer token-1")
postRec := httptest.NewRecorder()
serveIMGroupResource(postRec, postReq)
if postRec.Code != http.StatusForbidden {
t.Fatalf("member mute post status: got %d body=%s", postRec.Code, postRec.Body.String())
}
}
func TestServeIMGroupDissolveConsumesActionToken(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -6441,6 +6693,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("mid-3")
mockStore.EXPECT().GetUid().Return(types.Uid(103))
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaMessagePersist
@@ -6562,10 +6815,11 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
}
}
func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) {
func TestServeIMMessageForwardKafkaPublishUnknownReturnsUncertain(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUid().Return(types.Uid(104))
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaMessagePersist
@@ -6640,8 +6894,9 @@ func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) {
t.Fatalf("unexpected forward response: %#v", data)
}
got := items[0].(map[string]any)
if got["status"] != "pending" || got["reason"] != "MESSAGE_PERSIST_PENDING" || got["seq"].(float64) != 41 {
t.Fatalf("unexpected pending item: %#v", got)
if got["status"] != "uncertain" || got["reason"] != "MESSAGE_PERSIST_RESULT_UNKNOWN" ||
got["safe_to_retry"] != false || got["seq"].(float64) != 41 {
t.Fatalf("unexpected uncertain item: %#v", got)
}
if got["client_mid"] != "fwd_"+stableIMEventID("forward", "idem-forward-1", "separate", targetTopic, "grpSource", int64(14)) {
t.Fatalf("unexpected stable client mid: %#v", got)
@@ -6651,7 +6906,7 @@ func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) {
}
select {
case delivered := <-globals.hub.routeSrv:
t.Fatalf("pending forward should not broadcast immediately: %#v", delivered)
t.Fatalf("uncertain forward should not broadcast immediately: %#v", delivered)
default:
}
}
@@ -7643,6 +7898,139 @@ func TestServeIMMessageRecall(t *testing.T) {
}
}
func TestServeIMMessageMutations(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
actor := types.Uid(42)
expectIMBearer(t, ctrl, "token-1", 7, actor)
oldSubs := store.Subs
oldMessages := store.Messages
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), "grpRecall", actor, false).Return(&types.Subscription{
TenantID: 7,
User: actor.String(),
Topic: "grpRecall",
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
mutatedAt := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
messages.EXPECT().Mutations(types.TenantID(7), "grpRecall", actor, int64(1), 20).Return([]types.MessageMutation{{
SeqId: 9,
Version: 2,
Event: "message.recalled",
Operator: actor.UserId(),
MutatedAt: mutatedAt,
MutationVersion: 3,
}}, "3", false, int64(3), nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpRecall/message-mutations?since_version=1&limit=20", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMTopicMessageAction(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["mutation_version"].(float64) != 3 || data["next_cursor"] != "3" {
t.Fatalf("unexpected mutation response: %#v", data)
}
items := data["items"].([]any)
if len(items) != 1 {
t.Fatalf("unexpected mutation items: %#v", items)
}
item := items[0].(map[string]any)
if item["seq"].(float64) != 9 || item["version"].(float64) != 2 || item["event"] != "message.recalled" {
t.Fatalf("unexpected mutation item: %#v", item)
}
}
func TestServeIMMessageBatchRecallPartialSuccess(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
actor := types.Uid(42)
expectIMBearer(t, ctrl, "token-1", 7, actor)
oldSubs := store.Subs
oldMessages := store.Messages
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Subs = oldSubs
store.Messages = oldMessages
}()
sub := &types.Subscription{
TenantID: 7,
User: actor.String(),
Topic: "grpRecall",
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}
subs.EXPECT().Get(types.TenantID(7), "grpRecall", actor, false).Return(sub, nil).Times(2)
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{
TenantID: 7,
SeqId: 9,
Topic: "grpRecall",
From: actor.String(),
Version: 1,
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil)
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), actor, 1, gomock.Any()).Return(&types.Message{
TenantID: 7,
SeqId: 9,
Topic: "grpRecall",
From: actor.String(),
Version: 2,
RecalledAt: ptrTime(time.Now().UTC()),
RecalledBy: actor.String(),
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(10)).Return(&types.Message{
TenantID: 7,
SeqId: 10,
Topic: "grpRecall",
From: actor.String(),
Version: 2,
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil)
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(10), actor, 1, gomock.Any()).Return(nil, types.ErrDuplicate)
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/recall",
bytes.NewBufferString(`{"items":[{"seq":9},{"seq":10,"version":1}],"reason":"mistake"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMessageBatchRecall(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 2 {
t.Fatalf("items length: got %d, want 2", len(items))
}
first := items[0].(map[string]any)
if first["seq"].(float64) != 9 || first["status"] != "recalled" || first["version"].(float64) != 2 {
t.Fatalf("unexpected first item: %#v", first)
}
second := items[1].(map[string]any)
if second["seq"].(float64) != 10 || second["status"] != "failed" || second["reason"] != "VERSION_CONFLICT" {
t.Fatalf("unexpected second item: %#v", second)
}
}
func TestServeIMMessageRecallNormalizesP2PUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -7702,6 +8090,72 @@ func TestServeIMMessageRecallNormalizesP2PUserTopic(t *testing.T) {
}
}
func TestEnqueueIMP2PMessageMutationEventsDeliversUserDirtyMarker(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(7)
actor := types.Uid(42)
peer := types.Uid(43)
topic := actor.P2PName(peer)
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("actor-session", tenantID, actor, "me"),
realtimeTestSession("peer-session", tenantID, peer, "me"),
)
if err := enqueueIMP2PMessageMutationEvents(tenantID, topic, actor, 9, 2, "message.recalled"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
sid string
uid types.Uid
}{
{"actor-session", actor},
{"peer-session", peer},
} {
sess := globals.sessionStore.Get(tc.sid)
select {
case raw := <-sess.send:
msg, ok := raw.(*ServerComMessage)
if !ok || msg.Info == nil {
t.Fatalf("unexpected outbound for %s: %#v", tc.sid, raw)
}
if msg.Info.Topic != tc.uid.UserId() || msg.Info.Event != "message.mutation" || msg.Info.What != "im" {
t.Fatalf("unexpected mutation marker for %s: %#v", tc.sid, msg.Info)
}
var payload map[string]any
if err := json.Unmarshal(msg.Info.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["topic"] != topic || payload["event"] != "message.recalled" ||
payload["seq"].(float64) != 9 || payload["version"].(float64) != 2 {
t.Fatalf("unexpected mutation payload for %s: %#v", tc.sid, payload)
}
default:
t.Fatalf("expected mutation marker for %s", tc.sid)
}
}
}
func TestEnqueueIMP2PMessageMutationEventsIgnoresGroupTopic(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(7)
actor := types.Uid(42)
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("actor-session", tenantID, actor, "me"),
)
if err := enqueueIMP2PMessageMutationEvents(tenantID, "grpRecall", actor, 9, 2, "message.recalled"); err != nil {
t.Fatal(err)
}
if len(globals.sessionStore.Get("actor-session").send) != 0 {
t.Fatal("group topic should not emit per-user mutation markers")
}
}
func TestServeIMMessageRevision(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -8020,6 +8474,36 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
}
}
func TestServeIMFriendRequestCreateReturnsNotFoundForMissingTarget(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
_ = expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{err: types.ErrNotFound}
store.Relations = relations
store.Profiles = &testProfilesStore{}
defer func() {
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "fr-missing")
rec := httptest.NewRecorder()
serveIMFriendRequests(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
if relations.created != nil {
t.Fatalf("friend request should not be created: %#v", relations.created)
}
}
func TestServeIMFriendRequestCreateIgnoresRelationEventFailure(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -8123,7 +8607,11 @@ func TestPublishIMConversationSystemNoticeDeliversToOnlineP2PSessions(t *testing
if !ok || msg.Data == nil {
t.Fatalf("unexpected outbound for %s: %#v", uid.UserId(), raw)
}
if msg.Data.Topic != topic || msg.Data.SeqId != 1 {
wantTopic, err := types.P2PNameForUser(uid, topic)
if err != nil {
t.Fatal(err)
}
if msg.Data.Topic != wantTopic || msg.Data.SeqId != 1 {
t.Fatalf("unexpected p2p data for %s: %#v", uid.UserId(), msg.Data)
}
if msg.Data.Head["x-im-template"] != "friend_added" || msg.Data.Head["x-im-type"] != "system_notice" {
@@ -8501,6 +8989,7 @@ func TestServeIMUserProfile(t *testing.T) {
users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{
ObjHeader: types.ObjHeader{Id: peer.String()},
TenantID: 7,
Username: "alice7",
Public: map[string]any{"display_name": "Alice", "nickname": "Alice", "avatar_file_id": "file-1", "signature": "hello"},
}, nil)
store.Users = users
@@ -8530,7 +9019,7 @@ func TestServeIMUserProfile(t *testing.T) {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" {
if data["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" || data["username"] != "alice7" {
t.Fatalf("unexpected profile response: %#v", data)
}
if data["display_name"] != "老同事" || data["remark"] != "老同事" {
@@ -8541,6 +9030,70 @@ func TestServeIMUserProfile(t *testing.T) {
}
}
func TestServeIMUserProfileBlockedByMeReturnsMinimalProfile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
peer := types.Uid(2)
users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{
ObjHeader: types.ObjHeader{Id: peer.String()},
TenantID: 7,
Username: "alice7",
Public: map[string]any{
"display_name": "Alice",
"nickname": "Alice",
"avatar_file_id": "file-1",
"signature": "hello",
"gender": "female",
"region": "Shanghai",
"public_account": "alice001",
},
}, nil)
store.Users = users
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7,
UserID: 1,
PeerID: peer,
Topic: types.Uid(1).P2PName(peer),
Relationship: types.RelationshipBlockedByMe,
Remark: "老同事",
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMUserResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["relationship"] != types.RelationshipBlockedByMe || data["display_name"] != "老同事" ||
data["avatar_file_id"] != "file-1" || data["topic"] == "" || data["username"] != "alice7" {
t.Fatalf("unexpected blocked profile response: %#v", data)
}
capabilities := data["capabilities"].(map[string]any)
if capabilities["can_send"] != false || capabilities["can_call"] != false || capabilities["can_add_friend"] != false {
t.Fatalf("blocked profile should disable actions: %#v", data)
}
for _, field := range []string{"signature", "gender", "region", "public_account"} {
if _, ok := data[field]; ok {
t.Fatalf("blocked profile should omit private field %q: %#v", field, data)
}
}
}
func TestServeIMUserQRCodes(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+6
View File
@@ -639,6 +639,12 @@ func initTopicGrp(t *Topic) error {
return types.ErrTopicNotFound
}
// A dissolved group must not be loaded or re-subscribed to. Otherwise the
// group could be restored by any former member.
if stopic.State == types.StateDeleted {
return types.ErrTopicNotFound
}
if err = t.loadSubscribers(); err != nil {
return err
}
+78 -10
View File
@@ -73,9 +73,10 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
"audit-logs": {
Select: "SELECT id,request_id,actor_user_id,action,target_type,target_id,ip,user_agent,detail,created_at",
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"actor_user_id", "actor_user_id", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent,
JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`,
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"actor_user_id", "actor_uid", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"realname-verifications": {
Select: "SELECT id,user_id,submission_id,status,masked_name AS real_name,masked_id_number AS id_card_no_masked,review_reason AS reject_reason,verified_at AS reviewed_at,created_at,updated_at",
@@ -136,6 +137,14 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
if resource == "group-messages" {
var err error
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
}
query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, scope)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
@@ -283,7 +292,7 @@ func groupsInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT t.name AS topic,t.state,t.owner AS owner_id,t.seqid AS seq_id,t.delid AS del_id,t.subcnt AS sub_count,t.touchedat AS touched_at,t.createdat AS created_at,t.updatedat AS updated_at,t.public,t.trusted,t.access,t.tags,t.aux,
gs.name AS group_name,gs.avatar_file_id,gs.description,gs.group_status,gs.join_approval,gs.mute_all,gs.member_upload_files,gs.member_private_chat,gs.member_invite,gs.max_member_count,gs.max_admin_count,gs.member_count AS business_member_count,gs.version AS settings_version,gs.updated_at AS settings_updated_at`,
From: "FROM im_topics AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name",
From: "FROM (SELECT * FROM im_topics WHERE name LIKE 'grp%') AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name",
DefaultSort: "ORDER BY t.id DESC",
DetailKey: "t.name",
Filters: []internalQueryFilter{{"topic", "t.name", "LIKE", "string"}, {"owner_id", "t.owner", "=", "uid"}, {"state", "t.state", "=", "int"}, {"group_status", "gs.group_status", "=", "string"}, {"created_at_start", "t.createdat", ">=", "time"}, {"created_at_end", "t.createdat", "<=", "time"}, {"touched_at_start", "t.touchedat", ">=", "time"}, {"touched_at_end", "t.touchedat", "<=", "time"}},
@@ -346,14 +355,68 @@ func groupMessagesInternalQuerySpec() *internalQuerySpec {
}
}
func groupMessagesInternalQuerySpecForTenant(tenantID types.TenantID) (*internalQuerySpec, error) {
source, err := internalGroupMessageSource(tenantID)
if err != nil {
return nil, err
}
spec := groupMessagesInternalQuerySpec()
spec.From = strings.Replace(spec.From, "FROM im_group_messages AS m", "FROM "+source+" AS m", 1)
spec.DefaultSort = "ORDER BY m.created_at DESC,m.id DESC"
return spec, nil
}
func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
result, err := store.InternalAdminQueries.Query(tenantID,
"SELECT table_name FROM im_group_message_shards WHERE status<>'archived' ORDER BY shard_month DESC",
"SELECT COUNT(*) FROM im_group_message_shards WHERE status<>'archived'", nil, 1, 200)
if err != nil {
return "", err
}
selects := make([]string, 0)
for _, item := range result.Items {
table := strings.TrimSpace(fmt.Sprint(item["table_name"]))
if !validInternalGroupMessageTable(table) {
continue
}
selects = append(selects, fmt.Sprintf(`SELECT message_id AS id,topic,seqid,from_user,msg_type,status,content,head,reply_seqid,mentioned_user_ids,created_at,updated_at FROM %s`, table))
}
selects = append(selects, `SELECT id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user,
msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status,
content,head,reply_seqid,mentioned_user_ids,created_at,updated_at
FROM im_group_messages AS legacy WHERE NOT EXISTS (
SELECT 1 FROM im_group_message_topic_shards AS route
WHERE route.topic=legacy.topic COLLATE utf8mb4_0900_ai_ci AND legacy.seqid BETWEEN route.min_seq AND route.max_seq)`)
return "(" + strings.Join(selects, " UNION ALL ") + ")", nil
}
func validInternalGroupMessageTable(table string) bool {
const prefix = "im_group_messages_"
if len(table) != len(prefix)+6 || !strings.HasPrefix(table, prefix) {
return false
}
for _, ch := range table[len(prefix):] {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func serveInternalDashboardOverview(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
row, err := store.InternalAdminQueries.QueryOne(tenantID, `SELECT
groupMessages, err := internalGroupMessageSource(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
query := fmt.Sprintf(`SELECT
(SELECT COUNT(*) FROM im_users WHERE state<>20) AS total_users,
(SELECT COUNT(*) FROM im_users WHERE state=0 AND lastseen>=UTC_DATE()) AS active_users,
(SELECT COUNT(*) FROM im_group_settings WHERE group_status='active') AS total_groups,
(SELECT COUNT(*) FROM im_topics WHERE state<>20 AND (name LIKE 'p2p%' OR name LIKE 'grp%')) AS total_conversations,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%')+(SELECT COUNT(*) FROM im_group_messages)) AS total_messages,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%' AND createdat>=UTC_DATE())+(SELECT COUNT(*) FROM im_group_messages WHERE created_at>=UTC_DATE())) AS today_messages`, nil)
(SELECT COUNT(*) FROM im_topics WHERE state<>20 AND (name LIKE 'p2p%%' OR name LIKE 'grp%%')) AS total_conversations,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%%')+(SELECT COUNT(*) FROM %s AS gm)) AS total_messages,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%%' AND createdat>=UTC_DATE())+(SELECT COUNT(*) FROM %s AS gm WHERE gm.created_at>=UTC_DATE())) AS today_messages`, groupMessages, groupMessages)
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, nil)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
@@ -366,14 +429,19 @@ func serveInternalDashboardTrends(wrt http.ResponseWriter, req *http.Request, te
if days > 31 {
days = 31
}
groupMessages, err := internalGroupMessageSource(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
query := fmt.Sprintf(`WITH RECURSIVE dates AS (
SELECT UTC_DATE()-INTERVAL %d DAY AS day
UNION ALL SELECT day+INTERVAL 1 DAY FROM dates WHERE day<UTC_DATE()
) SELECT DATE_FORMAT(d.day,'%%Y-%%m-%%d') AS date,
(SELECT COUNT(*) FROM im_users AS u WHERE u.createdat>=d.day AND u.createdat<d.day+INTERVAL 1 DAY) AS user_count,
((SELECT COUNT(*) FROM im_messages AS pm WHERE pm.topic LIKE 'p2p%%' AND pm.createdat>=d.day AND pm.createdat<d.day+INTERVAL 1 DAY)+
(SELECT COUNT(*) FROM im_group_messages AS gm WHERE gm.created_at>=d.day AND gm.created_at<d.day+INTERVAL 1 DAY)) AS message_count
FROM dates AS d ORDER BY d.day ASC`, days-1)
(SELECT COUNT(*) FROM %s AS gm WHERE gm.created_at>=d.day AND gm.created_at<d.day+INTERVAL 1 DAY)) AS message_count
FROM dates AS d ORDER BY d.day ASC`, days-1, groupMessages)
result, err := store.InternalAdminQueries.Query(tenantID, query, fmt.Sprintf("SELECT %d", days), nil, 1, days)
if err != nil {
writeInternalQueryError(wrt, req, err)
+23 -25
View File
@@ -376,12 +376,10 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil)
return false
}
if req.Method != http.MethodGet {
requestClaim := internalRequestClaimValue(signingText)
if !claimInternalNonce(serviceName, nonce, requestClaim, time.Now().Add(window)) {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal nonce replayed", nil)
return false
}
requestClaim := internalRequestClaimValue(signingText)
if !claimInternalNonce(serviceName, nonce, requestClaim, time.Now().Add(window)) {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal nonce replayed", nil)
return false
}
return true
}
@@ -566,7 +564,11 @@ func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenan
where := "m.topic=? AND m.seqid=?"
if strings.HasPrefix(topic, "grp") {
resource = "group-messages"
spec = groupMessagesInternalQuerySpec()
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
}
spec.Select += ",m.head,JSON_UNQUOTE(m.content) AS content"
row, err := store.InternalAdminQueries.QueryOne(tenantID, spec.Select+" "+spec.From+" WHERE "+where, []any{topic, seq})
@@ -579,26 +581,22 @@ func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenan
}
func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) {
if fileID == "" || store.Files == nil {
if fileID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
file, err := store.Files.Get(tenantID, fileID)
fileDBID, ok := parseInternalAdminDBID(fileID)
if !ok {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
spec, _ := internalAdminQuerySpec("files")
row, err := store.InternalAdminQueries.QueryOne(tenantID, spec.Select+" "+spec.From+" WHERE id=?", []any{fileDBID})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"file_id": file.Id,
"status": file.Status,
"user_id": internalAdminUIDStringRaw(file.User),
"mime_type": file.MimeType,
"size": file.Size,
"location": file.Location,
"etag": file.ETag,
"created_at": file.CreatedAt,
"updated_at": file.UpdatedAt,
})
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalKafkaDeadLettersQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
@@ -705,6 +703,7 @@ func internalKafkaDeadLetterPayload(dead *types.KafkaDeadLetter, includeEnvelope
}
func writeInternalQueryError(wrt http.ResponseWriter, req *http.Request, err error) {
logs.Warn.Printf("internal admin query failed: method=%s path=%s err=%v", req.Method, req.URL.RequestURI(), err)
switch {
case errors.Is(err, types.ErrUserNotFound), errors.Is(err, types.ErrTopicNotFound), errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
@@ -772,12 +771,12 @@ func verifyInternalSignature(secret, signingText, signature string) bool {
func claimInternalNonce(serviceName, nonce, requestClaim string, expiresAt time.Time) bool {
ttl := time.Until(expiresAt)
nonceKey := fmt.Sprintf("%s:%s", serviceName, nonce)
if claimed, sameValue, enabled, err := store.ClaimShortStateValue("internal_api_nonce", nonceKey, requestClaim, ttl); enabled {
if claimed, _, enabled, err := store.ClaimShortStateValue("internal_api_nonce", nonceKey, requestClaim, ttl); enabled {
if err != nil {
logs.Warn.Println("internal api: failed to claim nonce in redis", err)
return false
}
return claimed || sameValue
return claimed
}
now := time.Now()
internalAPI.nonces.Range(func(key, value any) bool {
@@ -786,9 +785,8 @@ func claimInternalNonce(serviceName, nonce, requestClaim string, expiresAt time.
}
return true
})
if existing, loaded := internalAPI.nonces.LoadOrStore(nonceKey, internalNonceClaim{value: requestClaim, expiresAt: expiresAt}); loaded {
claim, ok := existing.(internalNonceClaim)
return ok && claim.value == requestClaim
if _, loaded := internalAPI.nonces.LoadOrStore(nonceKey, internalNonceClaim{value: requestClaim, expiresAt: expiresAt}); loaded {
return false
}
return true
}
+15
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/store"
@@ -18,6 +19,20 @@ func (internalAdminQueryNotFound) Query(types.TenantID, string, string, []any, i
return nil, types.ErrNotFound
}
func TestClaimInternalNonceRejectsReplay(t *testing.T) {
oldConfig := internalAPI.config
internalAPI = internalAPIRuntime{config: oldConfig}
defer func() { internalAPI = internalAPIRuntime{config: oldConfig} }()
expiresAt := time.Now().Add(time.Minute)
if !claimInternalNonce("manager-test", "single-use-nonce", "request-claim", expiresAt) {
t.Fatal("first nonce claim was rejected")
}
if claimInternalNonce("manager-test", "single-use-nonce", "request-claim", expiresAt) {
t.Fatal("replayed nonce was accepted")
}
}
func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[string]any, error) {
return nil, types.ErrNotFound
}
+2 -2
View File
@@ -437,7 +437,7 @@ func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEn
}
evicted := 0
if globals.sessionStore != nil {
evicted = globals.sessionStore.EvictUserDevice(target, deviceID)
evicted = globals.sessionStore.EvictUserDevice(target, deviceID, "admin_device_kick")
}
publishRealtimeEvictDevice(tenantID, target, deviceID, "admin_device_kick")
if store.Devices != nil {
@@ -500,7 +500,7 @@ func executeKafkaUserPasswordResetCommand(tenantID types.TenantID, payload admin
func evictUserSessions(tenantID types.TenantID, uid types.Uid) {
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(uid, "")
globals.sessionStore.EvictUser(uid, "", "admin_user_evict")
}
publishRealtimeEvictUser(tenantID, uid, "admin_user_evict", "")
}
+4
View File
@@ -29,6 +29,10 @@ func (r *messageAdminAuditRecorder) ApplyMessageAction(_ types.TenantID, action
return nil
}
func (r *messageAdminAuditRecorder) RecordContentModeration(types.TenantID, *types.ContentModerationLog) error {
return nil
}
func TestExecuteKafkaMessageAdminCommand(t *testing.T) {
previous := store.SecurityAudit
recorder := &messageAdminAuditRecorder{}
+13
View File
@@ -21,6 +21,8 @@ const (
configEventThirdPartyUpdated = "third_party.updated"
configEventRegistrationUpdated = "registration.config.updated"
configEventAppReleaseUpdated = "app.release.updated"
configEventSecurityPolicyUpdated = "security.policy.updated"
configEventSensitiveWordsUpdated = "sensitive_words.updated"
)
type configEventPayload struct {
@@ -83,17 +85,28 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error {
switch strings.TrimSpace(envelope.EventType) {
case configEventTenantUpdated, configEventTenantStatusChanged, configEventTenantLimitsUpdated, configEventTenantFeaturesUpdated:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
if strings.TrimSpace(envelope.EventType) == configEventTenantFeaturesUpdated {
store.InvalidateModerationConfigCache(tenantCode, false, true, false)
}
case configEventDictionaryUpdated, configEventThirdPartyUpdated:
store.InvalidateThirdPartyConfigCache(payload.Group)
if payload.Group == "moderation" {
store.InvalidateModerationConfigCache(tenantCode, true, false, false)
}
case configEventRegistrationUpdated:
store.InvalidateRegistrationConfigCache()
case configEventAppReleaseUpdated:
store.InvalidateAppReleaseConfigCache()
case configEventSecurityPolicyUpdated:
store.InvalidateModerationConfigCache(tenantCode, false, true, false)
case configEventSensitiveWordsUpdated:
store.InvalidateModerationConfigCache(tenantCode, false, false, true)
default:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
store.InvalidateThirdPartyConfigCache(payload.Group)
store.InvalidateRegistrationConfigCache()
store.InvalidateAppReleaseConfigCache()
store.InvalidateModerationConfigCache(tenantCode, true, true, true)
}
logs.Info.Printf("kafka config event applied: event=%s type=%s tenant=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, payload.Group)
return nil
+19 -3
View File
@@ -18,6 +18,7 @@ type kafkaMessagePersistPayload struct {
AttachmentURLs []string `json:"attachment_urls,omitempty"`
ReadBySender bool `json:"read_by_sender,omitempty"`
MessageKind string `json:"message_kind"`
ShardMonth string `json:"shard_month,omitempty"`
}
var publishKafkaMessagePersist = publishKafkaEnvelope
@@ -34,6 +35,11 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
if msg == nil || msg.TenantID != tenantID || msg.Topic == "" || msg.SeqId <= 0 {
return nil, types.ErrMalformed
}
msg.InitTimes()
if msg.Uid().IsZero() {
msg.SetUid(store.Store.GetUid())
}
isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp
clientMsgID, err := store.NormalizeClientMsgID(msg.Head)
if err != nil {
return nil, err
@@ -56,15 +62,21 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
return nil, types.ErrDuplicate
}
}
if seq, runtimeReady, seqErr := store.ReserveMessageSeq(tenantID, msg.Topic, msg.SeqId-1); seqErr != nil {
candidateMonth := msg.CreatedAt.UTC().Format("200601")
if seq, shardMonth, runtimeReady, seqErr := store.ReserveMessageSeq(tenantID, msg.Topic, msg.SeqId-1, candidateMonth); seqErr != nil {
if clientMsgID != "" {
_ = store.MarkMessageUniqueFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
return nil, seqErr
} else if runtimeReady {
msg.SeqId = seq
if isGroupMessage {
msg.ShardMonth = shardMonth
}
}
if msg.ShardMonth == "" && isGroupMessage {
msg.ShardMonth = candidateMonth
}
msg.InitTimes()
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
if clientMsgID != "" {
if markErr := store.MarkMessageUniquePending(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId); markErr != nil {
@@ -79,7 +91,7 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
logs.Warn.Printf("kafka message persist: failed to mark message unique key done topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
}
}
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
if isGroupMessage {
if cacheErr := store.CachePendingHotMessage(tenantID, msg); cacheErr != nil {
logs.Warn.Printf("kafka message persist: failed to cache pending hot message after publish topic=%s seq=%d: %v", msg.Topic, msg.SeqId, cacheErr)
}
@@ -97,6 +109,7 @@ func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, ms
AttachmentURLs: attachmentURLs,
ReadBySender: readBySender,
MessageKind: kind,
ShardMonth: msg.ShardMonth,
})
if err != nil {
return err
@@ -147,6 +160,9 @@ func handleKafkaMessagePersistFromTopic(ctx context.Context, envelope kafkaEnvel
if payload.Message.TenantID == types.ZeroTenantID {
payload.Message.TenantID = tenantID
}
if payload.Message.ShardMonth == "" {
payload.Message.ShardMonth = payload.ShardMonth
}
if payload.Message.TenantID != tenantID {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "TENANT_MISMATCH", "message tenant does not match envelope tenant")
}
+10 -1
View File
@@ -254,7 +254,7 @@ func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.U
if redirURL != "" {
return http.Header{
"Location": {redirURL},
"Location": {normalizeSignedRedirectURL(redirURL)},
"ETag": {`"` + fdef.ETag + `"`},
"Content-Type": {"application/json; charset=utf-8"},
"Cache-Control": {oh.conf.CacheControl},
@@ -263,6 +263,15 @@ func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.U
return nil, 0, nil
}
func normalizeSignedRedirectURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "http" || parsed.Host == "" {
return rawURL
}
parsed.Scheme = "https"
return parsed.String()
}
// Upload processes request for a file upload.
func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {
key, err := tenantObjectKey(fdef.TenantID, fdef.Uid().String32(), fdef.CreatedAt)
+11
View File
@@ -58,6 +58,17 @@ func TestIsOSSObjectNotFound(t *testing.T) {
}
}
func TestNormalizeSignedRedirectURL(t *testing.T) {
rawURL := "http://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/tenants/1/avatar.jpg?Expires=1&OSSAccessKeyId=ak&Signature=sig"
want := "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/tenants/1/avatar.jpg?Expires=1&OSSAccessKeyId=ak&Signature=sig"
if got := normalizeSignedRedirectURL(rawURL); got != want {
t.Fatalf("normalizeSignedRedirectURL()=%q, want %q", got, want)
}
if got := normalizeSignedRedirectURL("/v0/file/s/avatar.jpg"); got != "/v0/file/s/avatar.jpg" {
t.Fatalf("relative URL must be unchanged, got %q", got)
}
}
func TestLoadSysConfig(t *testing.T) {
original := store.ThirdPartyConfig
store.ThirdPartyConfig = fakeThirdPartyConfig{
+100
View File
@@ -0,0 +1,100 @@
package aliyun
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
green "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/tinode/chat/server/store/types"
)
type TextClient struct {
client *green.Client
timeout time.Duration
}
type Result struct {
ProviderRequestID string
RiskLevel string
RiskLabels []string
Summary string
Latency time.Duration
}
func NewTextClient(config *types.ModerationProviderConfig) (*TextClient, error) {
if config == nil || strings.TrimSpace(config.AccessKeyID) == "" || strings.TrimSpace(config.AccessKeySecret) == "" {
return nil, errors.New("missing aliyun moderation credentials")
}
clientConfig := &openapi.Config{AccessKeyId: dara.String(config.AccessKeyID), AccessKeySecret: dara.String(config.AccessKeySecret), RegionId: dara.String(config.RegionID), Protocol: dara.String("https")}
if endpoint := strings.TrimSpace(config.Endpoint); endpoint != "" {
clientConfig.Endpoint = dara.String(endpoint)
}
client, err := green.NewClient(clientConfig)
if err != nil {
return nil, err
}
timeout := time.Duration(config.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 1500 * time.Millisecond
}
return &TextClient{client: client, timeout: timeout}, nil
}
func (c *TextClient) Review(ctx context.Context, service, text, dataID string) (*Result, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
params, err := json.Marshal(map[string]string{"content": text, "dataId": dataID})
if err != nil {
return nil, err
}
started := time.Now()
resp, err := c.client.TextModerationPlusWithContext(ctx, &green.TextModerationPlusRequest{Service: dara.String(service), ServiceParameters: dara.String(string(params))}, &dara.RuntimeOptions{})
latency := time.Since(started)
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil || dara.Int32Value(resp.Body.Code) != 200 || resp.Body.Data == nil {
return nil, errors.New("aliyun text moderation failed")
}
result := &Result{ProviderRequestID: dara.StringValue(resp.Body.RequestId), RiskLevel: moderationRisk(dara.StringValue(resp.Body.Data.RiskLevel)), Latency: latency}
seen := map[string]bool{}
for _, item := range resp.Body.Data.Result {
if item == nil {
continue
}
label := strings.TrimSpace(dara.StringValue(item.Label))
if label != "" && !seen[label] {
seen[label] = true
result.RiskLabels = append(result.RiskLabels, label)
}
}
if ext := resp.Body.Data.Ext; ext != nil && ext.LlmContent != nil {
result.Summary = strings.TrimSpace(dara.StringValue(ext.LlmContent.OutputText))
if llmIndicatesRisk(result.Summary) && result.RiskLevel == "none" {
result.RiskLevel = "medium"
result.RiskLabels = append(result.RiskLabels, "llm_risk")
}
}
return result, nil
}
func moderationRisk(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "high", "medium", "low":
return strings.ToLower(strings.TrimSpace(value))
default:
return "none"
}
}
func llmIndicatesRisk(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
return !strings.Contains(value, "无风险") && !strings.HasPrefix(value, "正常") && (strings.Contains(value, "风险") || strings.Contains(value, "违规") || strings.Contains(value, "诈骗") || strings.Contains(value, "引流"))
}
+34
View File
@@ -0,0 +1,34 @@
package moderation
import "strings"
var visibleTextKeys = map[string]bool{
"txt": true, "text": true, "title": true, "caption": true,
"description": true, "file_name": true, "filename": true, "name": true,
}
func ExtractText(content any) string {
parts := make([]string, 0, 4)
var walk func(any, int, bool)
walk = func(value any, depth int, includeString bool) {
if depth > 6 || value == nil {
return
}
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); includeString && text != "" {
parts = append(parts, text)
}
case map[string]any:
for key, item := range typed {
walk(item, depth+1, visibleTextKeys[strings.ToLower(strings.TrimSpace(key))])
}
case []any:
for _, item := range typed {
walk(item, depth+1, includeString)
}
}
}
walk(content, 0, true)
return strings.TrimSpace(strings.Join(parts, "\n"))
}
+74
View File
@@ -0,0 +1,74 @@
package moderation
import (
"regexp"
"strings"
"github.com/mozillazg/go-pinyin"
"github.com/tinode/chat/server/store/types"
)
func matchLocalWords(text, scene string, words *types.SensitiveWordSet) *ProviderResult {
normalized := strings.ToLower(strings.TrimSpace(text))
if normalized == "" || words == nil {
return nil
}
for _, rule := range words.Items {
if rule == nil || strings.TrimSpace(rule.Word) == "" || !sceneEnabled(rule.Scenes, scene) {
continue
}
matched := false
switch strings.ToLower(strings.TrimSpace(rule.MatchType)) {
case "exact":
matched = normalized == strings.ToLower(strings.TrimSpace(rule.Word))
case "regex":
pattern, err := regexp.Compile(rule.Word)
matched = err == nil && pattern.MatchString(text)
case "pinyin":
args := pinyin.NewArgs()
args.Style = pinyin.Normal
textPinyin := strings.Join(pinyin.LazyConvert(text, &args), "")
wordPinyin := strings.Join(pinyin.LazyConvert(rule.Word, &args), "")
if wordPinyin == "" {
wordPinyin = strings.ToLower(strings.TrimSpace(rule.Word))
}
matched = wordPinyin != "" && strings.Contains(strings.ToLower(textPinyin), strings.ToLower(wordPinyin))
default:
matched = strings.Contains(normalized, strings.ToLower(strings.TrimSpace(rule.Word)))
}
if matched {
return &ProviderResult{RiskLevel: normalizedRisk(rule.RiskLevel), RiskLabels: []string{"local_sensitive_word"}, Summary: normalizedAction(rule.Action)}
}
}
return nil
}
func sceneEnabled(scenes []string, scene string) bool {
if len(scenes) == 0 {
return true
}
for _, value := range scenes {
if value == "*" || value == scene {
return true
}
}
return false
}
func normalizedRisk(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "high", "medium", "low":
return strings.ToLower(strings.TrimSpace(value))
default:
return "none"
}
}
func normalizedAction(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case ActionBlock, ActionAuditOnly:
return strings.ToLower(strings.TrimSpace(value))
default:
return ActionAllow
}
}
+67
View File
@@ -0,0 +1,67 @@
package moderation
import (
"context"
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testConfig struct {
policy *types.ModerationPolicy
words *types.SensitiveWordSet
}
func (c testConfig) Provider() (*types.ModerationProviderConfig, error) {
return &types.ModerationProviderConfig{}, nil
}
func (c testConfig) Policy(string) (*types.ModerationPolicy, error) { return c.policy, nil }
func (c testConfig) Words(string) (*types.SensitiveWordSet, error) { return c.words, nil }
type testAudit struct{}
func (testAudit) RecordLogin(types.TenantID, *types.LoginAuditEvent) error { return nil }
func (testAudit) RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error { return nil }
func (testAudit) CloseLogin(types.TenantID, string, time.Time) error { return nil }
func (testAudit) ApplyMessageAction(types.TenantID, *types.MessageAdminAction) error { return nil }
func (testAudit) RecordContentModeration(types.TenantID, *types.ContentModerationLog) error {
return nil
}
func TestExtractText(t *testing.T) {
content := map[string]any{"txt": "正文", "ent": []any{map[string]any{"data": map[string]any{"title": "查看详情", "url": "https://example.com"}}}, "meta": "ignore"}
if got := ExtractText(content); got != "正文\n查看详情" && got != "查看详情\n正文" {
t.Fatalf("ExtractText()=%q", got)
}
}
func TestReviewLocalBlock(t *testing.T) {
oldConfig, oldAudit := store.ModerationConfig, store.SecurityAudit
defer func() { store.ModerationConfig, store.SecurityAudit = oldConfig, oldAudit }()
store.ModerationConfig = testConfig{policy: &types.ModerationPolicy{Enabled: true, LocalWordsEnabled: true, Scenes: map[string]bool{ScenePrivateMessage: true}, RiskActions: map[string]string{"high": ActionBlock}}, words: &types.SensitiveWordSet{Items: []*types.SensitiveWordRule{{Word: "违规词", MatchType: "contains", Scenes: []string{ScenePrivateMessage}, Action: ActionBlock, RiskLevel: "high"}}}}
store.SecurityAudit = testAudit{}
result, err := Review(context.Background(), Request{TenantID: 1, TenantCode: "default", Scene: ScenePrivateMessage, Text: "包含违规词的消息"})
if err != ErrRejected || result.Action != ActionBlock || result.Provider != "local" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestReviewOtherTenantUsesOwnPolicy(t *testing.T) {
oldConfig, oldAudit := store.ModerationConfig, store.SecurityAudit
defer func() { store.ModerationConfig, store.SecurityAudit = oldConfig, oldAudit }()
store.ModerationConfig = testConfig{policy: &types.ModerationPolicy{Enabled: true, LocalWordsEnabled: true, Scenes: map[string]bool{ScenePrivateMessage: true}, RiskActions: map[string]string{"high": ActionBlock}}, words: &types.SensitiveWordSet{Items: []*types.SensitiveWordRule{{Word: "违规词", MatchType: "contains", Scenes: []string{ScenePrivateMessage}, Action: ActionBlock, RiskLevel: "high"}}}}
store.SecurityAudit = testAudit{}
result, err := Review(context.Background(), Request{TenantID: 2, TenantCode: "team", Scene: ScenePrivateMessage, Text: "违规词"})
if err != ErrRejected || result.Action != ActionBlock {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestMatchLocalWordsPinyin(t *testing.T) {
result := matchLocalWords("这里包含敏感内容", SceneGroupMessage, &types.SensitiveWordSet{Items: []*types.SensitiveWordRule{{Word: "mingan", MatchType: "pinyin", Scenes: []string{SceneGroupMessage}, RiskLevel: "medium"}}})
if result == nil || result.RiskLevel != "medium" {
t.Fatalf("result=%+v", result)
}
}
+62
View File
@@ -0,0 +1,62 @@
package moderation
import (
"context"
"errors"
"time"
"github.com/tinode/chat/server/store/types"
)
const (
SceneUserNickname = "user_nickname"
SceneUserSignature = "user_signature"
SceneGroupName = "group_name"
SceneGroupNickname = "group_nickname"
ScenePrivateMessage = "private_message"
SceneGroupMessage = "group_message"
SceneGroupAnnouncement = "group_announcement"
ActionAllow = "allow"
ActionBlock = "block"
ActionAuditOnly = "audit_only"
)
var (
ErrRejected = errors.New("content rejected")
ErrUnavailable = errors.New("content review unavailable")
)
type Request struct {
TenantID types.TenantID
TenantCode string
Scene string
ActorID types.Uid
Topic string
Text string
TraceID string
}
type ProviderResult struct {
ProviderRequestID string
RiskLevel string
RiskLabels []string
Summary string
Latency time.Duration
}
type Result struct {
RequestID string
Action string
RiskLevel string
RiskLabels []string
Provider string
ProviderRequestID string
Latency time.Duration
BlockMessage string
Err error
}
type Provider interface {
Review(context.Context, string, string, string) (*ProviderResult, error)
}
+172
View File
@@ -0,0 +1,172 @@
package moderation
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/moderation/aliyun"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type circuitState struct {
sync.Mutex
failures int
openUntil time.Time
}
var aliyunCircuit circuitState
func Review(ctx context.Context, req Request) (*Result, error) {
text := strings.TrimSpace(req.Text)
result := &Result{RequestID: moderationRequestID(), Action: ActionAllow, RiskLevel: "none"}
if text == "" || strings.TrimSpace(req.TenantCode) == "" {
return result, nil
}
policy, err := store.ModerationConfig.Policy(req.TenantCode)
if err != nil {
return failureResult(result, nil, err)
}
if policy == nil || !policy.Enabled || !policy.Scenes[req.Scene] {
return result, nil
}
defer func() { recordDecision(req, text, result) }()
result.BlockMessage = policy.BlockMessage
if policy.LocalWordsEnabled {
words, wordsErr := store.ModerationConfig.Words(req.TenantCode)
if wordsErr != nil {
if logs.Warn != nil {
logs.Warn.Printf("moderation local words unavailable: tenant=%s scene=%s err=%v", req.TenantCode, req.Scene, wordsErr)
}
} else if localResult := matchLocalWords(text, req.Scene, words); localResult != nil {
result.Provider, result.RiskLevel, result.RiskLabels = "local", localResult.RiskLevel, localResult.RiskLabels
result.Action = actionForRisk(policy, localResult.RiskLevel)
if localResult.Summary == ActionBlock {
result.Action = ActionBlock
}
if result.Action == ActionBlock {
result.Err = ErrRejected
return result, ErrRejected
}
}
}
if !policy.AliyunEnabled {
return result, nil
}
providerConfig, err := store.ModerationConfig.Provider()
if err != nil || providerConfig == nil || !providerConfig.Enabled {
return failureResult(result, policy, firstError(err, ErrUnavailable))
}
if circuitOpen() {
return failureResult(result, policy, ErrUnavailable)
}
provider, err := aliyun.NewTextClient(providerConfig)
if err != nil {
recordFailure()
return failureResult(result, policy, err)
}
service := providerConfig.ChatService
if req.Scene == SceneUserNickname || req.Scene == SceneUserSignature || req.Scene == SceneGroupName || req.Scene == SceneGroupNickname {
service = providerConfig.NicknameService
}
providerResult, err := provider.Review(ctx, service, text, result.RequestID)
if err != nil {
recordFailure()
return failureResult(result, policy, err)
}
recordSuccess()
result.Provider, result.ProviderRequestID, result.RiskLevel, result.RiskLabels, result.Latency = "aliyun_green", providerResult.ProviderRequestID, providerResult.RiskLevel, providerResult.RiskLabels, providerResult.Latency
result.Action = actionForRisk(policy, result.RiskLevel)
if result.Action == ActionBlock {
result.Err = ErrRejected
return result, ErrRejected
}
return result, nil
}
func failureResult(result *Result, policy *types.ModerationPolicy, err error) (*Result, error) {
result.Err = err
failureMode := "allow"
if policy != nil && policy.FailureMode == "block" {
failureMode = "block"
result.BlockMessage = policy.BlockMessage
}
if failureMode == "block" {
result.Action = ActionBlock
return result, ErrUnavailable
}
result.Action = ActionAuditOnly
return result, nil
}
func actionForRisk(policy *types.ModerationPolicy, risk string) string {
if policy != nil {
if action := normalizedAction(policy.RiskActions[normalizedRisk(risk)]); action != "" {
return action
}
}
return ActionAllow
}
func firstError(err, fallback error) error {
if err != nil {
return err
}
return fallback
}
func moderationRequestID() string {
var raw [12]byte
if _, err := rand.Read(raw[:]); err != nil {
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(raw[:])
}
func circuitOpen() bool {
aliyunCircuit.Lock()
defer aliyunCircuit.Unlock()
return time.Now().Before(aliyunCircuit.openUntil)
}
func recordFailure() {
aliyunCircuit.Lock()
defer aliyunCircuit.Unlock()
aliyunCircuit.failures++
if aliyunCircuit.failures >= 5 {
aliyunCircuit.openUntil = time.Now().Add(30 * time.Second)
aliyunCircuit.failures = 0
}
}
func recordSuccess() {
aliyunCircuit.Lock()
aliyunCircuit.failures = 0
aliyunCircuit.openUntil = time.Time{}
aliyunCircuit.Unlock()
}
func logDecision(req Request, result *Result) {
if logs.Info != nil {
logs.Info.Printf("content moderation: request=%s tenant=%s scene=%s actor=%s provider=%s risk=%s action=%s latency_ms=%d", result.RequestID, req.TenantCode, req.Scene, req.ActorID.UserId(), result.Provider, result.RiskLevel, result.Action, result.Latency.Milliseconds())
}
}
func recordDecision(req Request, text string, result *Result) {
logDecision(req, result)
sum := sha256.Sum256([]byte(text))
errorCode := ""
if result.Err != nil {
errorCode = "PROVIDER_ERROR"
}
if err := store.SecurityAudit.RecordContentModeration(req.TenantID, &types.ContentModerationLog{
RequestID: result.RequestID, Scene: req.Scene, Topic: req.Topic, ActorID: req.ActorID,
Provider: result.Provider, ProviderRequestID: result.ProviderRequestID, RiskLevel: result.RiskLevel,
RiskLabels: result.RiskLabels, Action: result.Action, ContentHash: hex.EncodeToString(sum[:]),
LatencyMS: result.Latency.Milliseconds(), ErrorCode: errorCode, CreatedAt: time.Now().UTC().Round(time.Millisecond),
}); err != nil {
if logs.Warn != nil {
logs.Warn.Printf("content moderation audit write failed: request=%s tenant=%s err=%v", result.RequestID, req.TenantCode, err)
}
}
}
+17 -5
View File
@@ -451,7 +451,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams,
target = ""
}
globals.hub.routeSrv <- &ServerComMessage{
msg := &ServerComMessage{
TenantID: t.tenantID,
Pres: &MsgServerPres{
Topic: "me",
@@ -471,6 +471,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams,
RcptTo: user,
SkipSid: skipSid,
}
routeUserServerMessage(uid, msg)
}
}
@@ -485,7 +486,7 @@ func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int64, skipSid
continue
}
globals.hub.routeSrv <- &ServerComMessage{
msg := &ServerComMessage{
TenantID: t.tenantID,
Info: &MsgServerInfo{
Topic: "me",
@@ -498,6 +499,7 @@ func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int64, skipSid
RcptTo: uid.UserId(),
SkipSid: skipSid,
}
routeUserServerMessage(uid, msg)
}
}
@@ -530,7 +532,7 @@ func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string,
if offlineOnly {
msg.Info.SkipTopic = t.name
}
globals.hub.routeSrv <- msg
routeUserServerMessage(target, msg)
}
// Same as presSubsOffline, but the topic has not been loaded/initialized first: offline topic, offline subscribers
@@ -604,7 +606,7 @@ func (t *Topic) presSingleUserOffline(uid types.Uid, mode types.AccessMode,
target = ""
}
globals.hub.routeSrv <- &ServerComMessage{
msg := &ServerComMessage{
TenantID: t.tenantID,
Pres: &MsgServerPres{
Topic: "me",
@@ -622,6 +624,7 @@ func (t *Topic) presSingleUserOffline(uid types.Uid, mode types.AccessMode,
RcptTo: user,
SkipSid: skipSid,
}
routeUserServerMessage(uid, msg)
}
}
@@ -639,7 +642,7 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin
target = ""
}
globals.hub.routeSrv <- &ServerComMessage{
msg := &ServerComMessage{
TenantID: tenantID,
Pres: &MsgServerPres{
Topic: "me",
@@ -654,6 +657,15 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin
RcptTo: uid.UserId(),
SkipSid: skipSid,
}
routeUserServerMessage(uid, msg)
}
func routeUserServerMessage(uid types.Uid, msg *ServerComMessage) {
if msg == nil || uid.IsZero() {
return
}
globals.hub.routeSrv <- msg
publishRealtimeUserMessage(uid, msg)
}
// Let other sessions of a given user know what messages are now received/read.
+10
View File
@@ -10,7 +10,9 @@ package main
import (
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/push"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -73,6 +75,14 @@ func (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData, msgMarkedAsR
// Make sure the sender's devices receive a silent push.
online = 1
}
if online == 0 {
presence, err := store.GetUserPresence(t.tenantID, uid)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to read user presence for push: uid=%s err=%v", t.name, uid.UserId(), err)
} else if presence != nil && presence.Online && presence.SessionCount > 0 {
online = presence.SessionCount
}
}
// Send only to those who have notifications enabled.
mode := pud.modeWant & pud.modeGiven
+82 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
@@ -19,6 +20,7 @@ const (
realtimeTargetTopic = "topic"
realtimeTargetUser = "user"
realtimeTargetDevice = "device"
realtimeTargetPlatf = "platform"
)
func startRedisRealtimeFanout(ctx context.Context) {
@@ -105,6 +107,45 @@ func publishRealtimeEvictDevice(tenantID types.TenantID, uid types.Uid, deviceID
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetDevice, "ok")
}
// publishRealtimeEvictUserPlatform evicts all sessions of the user on the given
// platform across cluster nodes. skipSid (the new login session) is part of the
// dedup key because repeated logins on the same platform within the dedup window
// must each reach the subscriber nodes.
func publishRealtimeEvictUserPlatform(tenantID types.TenantID, uid types.Uid, platf, reason, skipSid string) {
if !tenantID.IsValid() || uid.IsZero() || strings.TrimSpace(platf) == "" {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetPlatf, "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(platf)
dedupKey := "evict:platform:" + target + ":" + skipSid
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetPlatf, target, skipSid, dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetPlatf, "error")
logs.Warn.Printf("redis realtime fanout: publish evict platform=%s failed: %v", target, err)
return
}
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetPlatf, "ok")
}
// fanoutEvictReason extracts the machine-readable eviction reason from a
// session-control fanout message, if present.
func fanoutEvictReason(msg *ServerComMessage) string {
if msg == nil || msg.Ctrl == nil {
return ""
}
params, ok := msg.Ctrl.Params.(map[string]any)
if !ok {
return ""
}
reason, _ := params["reason"].(string)
return reason
}
func deliverRealtimeFanoutEnvelope(envelope *store.RealtimeFanoutEnvelope) {
if envelope == nil || !envelope.TenantID.IsValid() || len(envelope.Message) == 0 {
immetrics.IncRealtimeFanout("receive", realtimeKindMessage, realtimeTargetTopic, "skip_invalid")
@@ -120,6 +161,14 @@ func deliverRealtimeFanoutEnvelope(envelope *store.RealtimeFanoutEnvelope) {
msg.TenantID = envelope.TenantID
msg.RcptTo = envelope.Target
msg.SkipSid = envelope.SkipSid
if envelope.SkipTopic != "" {
if msg.Pres != nil {
msg.Pres.SkipTopic = envelope.SkipTopic
}
if msg.Info != nil {
msg.Info.SkipTopic = envelope.SkipTopic
}
}
msg.sess = nil
prepareRedisGroupFanoutMessage(&msg, envelope.Target)
@@ -183,11 +232,33 @@ func deliverRealtimeTopicMessage(envelope *store.RealtimeFanoutEnvelope, msg *Se
recordRealtimeFanoutDeliverResult(envelope.Kind, realtimeTargetTopic, delivered)
}
// fanoutSessionControlStaleLimit caps how long after publication a
// session-control (evict) fanout message remains actionable. Delayed replays
// (consumer backlog, pending-entry redelivery after restart) carry the original
// skipSid, so a replayed evict would otherwise terminate freshly established
// sessions whose sid differs from the stale skipSid (新登录被旧消息误踢).
const fanoutSessionControlStaleLimit = 10 * time.Second
// fanoutSessionControlStale reports whether the session-control fanout message
// is too old to act on. Messages without a timestamp (unknown origin) are
// delivered to stay compatible with legacy publishers.
func fanoutSessionControlStale(msg *ServerComMessage) bool {
if msg == nil || msg.Ctrl == nil || msg.Ctrl.Timestamp.IsZero() {
return false
}
return time.Since(msg.Ctrl.Timestamp) > fanoutSessionControlStaleLimit
}
func deliverRealtimeSessionControl(envelope *store.RealtimeFanoutEnvelope, msg *ServerComMessage) {
if globals.sessionStore == nil {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_no_session_store")
return
}
if fanoutSessionControlStale(msg) {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_stale")
logs.Warn.Printf("redis realtime fanout: dropped stale session-control message target=%s older=%s", envelope.Target, fanoutSessionControlStaleLimit)
return
}
switch envelope.TargetType {
case realtimeTargetDevice:
uidText, deviceID, ok := strings.Cut(envelope.Target, ":")
@@ -195,7 +266,15 @@ func deliverRealtimeSessionControl(envelope *store.RealtimeFanoutEnvelope, msg *
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
return
}
globals.sessionStore.EvictUserDevice(types.ParseUserId(uidText), deviceID)
globals.sessionStore.EvictUserDevice(types.ParseUserId(uidText), deviceID, fanoutEvictReason(msg))
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
case realtimeTargetPlatf:
uidText, platf, ok := strings.Cut(envelope.Target, ":")
if !ok {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
return
}
globals.sessionStore.EvictUserPlatform(types.ParseUserId(uidText), platf, envelope.SkipSid, fanoutEvictReason(msg))
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
case realtimeTargetUser:
uid := types.ParseUserId(envelope.Target)
@@ -203,7 +282,7 @@ func deliverRealtimeSessionControl(envelope *store.RealtimeFanoutEnvelope, msg *
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
return
}
globals.sessionStore.EvictUser(uid, envelope.SkipSid)
globals.sessionStore.EvictUser(uid, envelope.SkipSid, fanoutEvictReason(msg))
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
}
_ = msg
@@ -229,7 +308,7 @@ func realtimeMessageDedupKey(target string, msg *ServerComMessage) string {
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)
return "info:" + target + ":" + msg.Info.What + ":" + msg.Info.From + ":" + 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)
+178
View File
@@ -52,6 +52,62 @@ func TestRealtimeEnvelopeDeliversToUserSessions(t *testing.T) {
}
}
func TestRealtimeUserPresenceSkipsSessionsAttachedToTopic(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
sender := types.Uid(1001)
recipient := types.Uid(1002)
topic := sender.P2PName(recipient)
if topic == "" {
t.Fatal("failed to create p2p topic")
}
visibleTopic, err := types.P2PNameForUser(recipient, topic)
if err != nil || visibleTopic == "" {
t.Fatalf("failed to create visible topic: %v", err)
}
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-attached", tenantID, recipient, visibleTopic),
realtimeTestSession("sid-me-only", tenantID, recipient, ""),
)
msg := &ServerComMessage{
Pres: &MsgServerPres{
Topic: "me",
Src: topic,
What: "msg",
SeqId: 35,
SkipTopic: topic,
},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindMessage,
TargetType: realtimeTargetUser,
Target: recipient.UserId(),
SkipTopic: topic,
Message: raw,
})
if len(globals.sessionStore.Get("sid-attached").send) != 0 {
t.Fatal("attached p2p session must not receive duplicate me presence")
}
select {
case got := <-globals.sessionStore.Get("sid-me-only").send:
pres := got.(*ServerComMessage).Pres
if pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != topic || pres.SeqId != 35 {
t.Fatalf("unexpected presence: %#v", pres)
}
default:
t.Fatal("me-only session must receive message presence")
}
}
func TestRealtimeP2PTopicFallbackRewritesVisibleTopic(t *testing.T) {
oldStore := globals.sessionStore
oldHub := globals.hub
@@ -103,6 +159,121 @@ func TestRealtimeP2PTopicFallbackRewritesVisibleTopic(t *testing.T) {
}
}
func TestRealtimeSessionControlStaleEvictDropped(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
uid := types.Uid(1001)
globals.sessionStore = realtimeTestSessionStore(
realtimeTestPlatformSession("sid-live", tenantID, uid, "web"),
)
envelope := func(ts time.Time) *store.RealtimeFanoutEnvelope {
msg := &ServerComMessage{
Ctrl: &MsgServerCtrl{
Code: 205,
Text: "evicted",
Timestamp: ts,
Params: map[string]any{"reason": "replaced_by_new_login"},
},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
return &store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindSessionControl,
TargetType: realtimeTargetPlatf,
Target: uid.UserId() + ":web",
SkipSid: "sid-new",
Message: raw,
}
}
// A stale replay (old skipSid != current session sid) must not evict.
deliverRealtimeFanoutEnvelope(envelope(time.Now().Add(-2 * fanoutSessionControlStaleLimit)))
if globals.sessionStore.Get("sid-live") == nil {
t.Fatal("stale evict must not terminate the session")
}
// A fresh evict must evict as usual.
deliverRealtimeFanoutEnvelope(envelope(time.Now()))
if globals.sessionStore.Get("sid-live") != nil {
t.Fatal("fresh evict must terminate the session")
}
}
func TestRealtimeP2PTopicFallbackRewritesVisibleInfoTopic(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{
Info: &MsgServerInfo{Topic: topic, From: uid1.UserId(), What: "read", SeqId: 13},
}
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:
info := got.(*ServerComMessage).Info
if info.Topic != visibleTopic {
t.Fatalf("%s got info topic %q, want %q", sid, info.Topic, visibleTopic)
}
if info.What != "read" || info.SeqId != 13 || info.From != uid1.UserId() {
t.Fatalf("%s got unexpected info %#v", sid, info)
}
default:
t.Fatalf("expected p2p info delivery to %s", sid)
}
}
}
func TestRealtimeInfoDedupKeySeparatesReadRecv(t *testing.T) {
read := realtimeMessageDedupKey("p2pABC", &ServerComMessage{
Info: &MsgServerInfo{From: "usrA", What: "read", SeqId: 12},
})
recv := realtimeMessageDedupKey("p2pABC", &ServerComMessage{
Info: &MsgServerInfo{From: "usrA", What: "recv", SeqId: 12},
})
if read == recv {
t.Fatalf("read and recv info dedup keys must differ: %q", read)
}
if read != "info:p2pABC:read:usrA::12" {
t.Fatalf("unexpected read dedup key %q", read)
}
}
func realtimeTestSessionStore(sessions ...*Session) *SessionStore {
ss := NewSessionStore(time.Minute)
for _, sess := range sessions {
@@ -117,6 +288,7 @@ func realtimeTestSession(sid string, tenantID types.TenantID, uid types.Uid, sub
tenantID: tenantID,
uid: uid,
send: make(chan any, 4),
stop: make(chan any, 1),
subs: make(map[string]*Subscription),
}
if sub != "" {
@@ -124,3 +296,9 @@ func realtimeTestSession(sid string, tenantID types.TenantID, uid types.Uid, sub
}
return sess
}
func realtimeTestPlatformSession(sid string, tenantID types.TenantID, uid types.Uid, platf string) *Session {
sess := realtimeTestSession(sid, tenantID, uid, "")
sess.platf = platf
return sess
}
+41 -2
View File
@@ -749,7 +749,14 @@ func (s *Session) subscribe(msg *ClientComMessage) {
s.inflightReqs.Add(1)
// Session can subscribe to topic on behalf of a single user at a time.
if sub := s.getSub(msg.RcptTo); sub != nil {
// Fast-path the duplicate attach only when the client used the canonical
// topic name itself. A p2p topic may also be addressed by an alias (the
// peer's user id, e.g. "usrXXX") while the session is already attached to
// the canonical topic ("p2pXXX"): forwarding it to the topic lets the
// client complete the alias->canonical rename with a 200 reply instead of
// a 304 that would leave the client-side alias topic object unattached
// (publishes on it then fail with "Cannot publish on inactive topic").
if sub := s.getSub(msg.RcptTo); sub != nil && canReplyAlreadySubscribed(msg.Original, msg.RcptTo) {
s.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp))
s.inflightReqs.Done()
} else {
@@ -765,6 +772,18 @@ func (s *Session) subscribe(msg *ClientComMessage) {
}
}
func canReplyAlreadySubscribed(original, routeTo string) bool {
if original == routeTo {
return true
}
switch original {
case "me", "fnd", "slf":
return true
default:
return false
}
}
// Leave/Unsubscribe a topic
func (s *Session) leave(msg *ClientComMessage) {
// Expand topic name
@@ -1286,6 +1305,11 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
if err := store.MarkUserOnline(s.tenantID, s.uid, s.sid, currentNodeID(), s.platf, s.userAgent); err != nil {
logs.Warn.Println("s.onLogin: failed to mark user online", s.uid, err, s.sid)
}
// 同平台抢登互踢:新登录踢掉同平台旧会话,异平台共存。
if globals.sessionStore != nil {
globals.sessionStore.EvictUserPlatform(rec.Uid, s.platf, s.sid, "replaced_by_new_login")
}
publishRealtimeEvictUserPlatform(s.tenantID, rec.Uid, s.platf, "replaced_by_new_login", s.sid)
// Reset expiration time.
rec.Lifetime = 0
}
@@ -1317,7 +1341,22 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// GenSecret fails only if tokenLifetime is < 0. It can't be < 0 here,
// otherwise login would have failed earlier.
rec.Features = features
params["token"], params["expires"], _ = store.Store.GetLogicalAuthHandler("token").GenSecret(rec)
token, expires, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(rec)
params["token"], params["expires"] = token, expires
if !s.uid.IsZero() && features&auth.FeatureNoLogin == 0 {
if enabled, err := store.StoreLoginSession(token, store.LoginSession{
UserID: store.DecodeUid(rec.Uid),
TenantID: int64(s.tenantID),
TenantCode: s.tenantCode,
DeviceID: s.deviceID,
AuthLevel: rec.AuthLevel.String(),
State: "active",
ExpiresAt: expires.Format(time.RFC3339),
IssuedAt: time.Now().UTC().Format(time.RFC3339),
}, time.Until(expires)); enabled && err != nil {
logs.Warn.Println("s.onLogin: failed to store login session", rec.Uid, err, s.sid)
}
}
reply.Ctrl.Params = params
return reply
+109 -7
View File
@@ -1321,6 +1321,8 @@ func TestDispatchAccNew(t *testing.T) {
oldSecurity := store.Security
oldVerifications := store.Verifications
oldInvites := store.RegistrationInvites
oldValidators := globals.validators
globals.validators = map[string]credValidator{"tel": {}}
store.Store = ss
store.Users = uu
store.Security = &testSecurityStore{version: 1}
@@ -1329,12 +1331,15 @@ func TestDispatchAccNew(t *testing.T) {
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
}}
store.RegistrationInvites = invites
setRegistrationSMSConfigForTest(true)
defer func() {
store.Store = nil
store.Users = nil
store.Security = oldSecurity
store.Verifications = oldVerifications
store.RegistrationInvites = oldInvites
globals.validators = oldValidators
resetRegistrationSMSConfigForTest()
ctrl.Finish()
}()
@@ -1347,10 +1352,14 @@ func TestDispatchAccNew(t *testing.T) {
State: types.StateOK,
}
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
ss.EXPECT().GetValidator("tel").Return(registrationTestValidator{})
// This login is available.
aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil)
uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn(
func(_ types.TenantID, user *types.User, private any) (*types.User, error) {
if user.Phone != "+8613800138000" {
t.Fatalf("unexpected registration phone: %q", user.Phone)
}
user.SetUid(uid)
return user, nil
})
@@ -1378,13 +1387,17 @@ func TestDispatchAccNew(t *testing.T) {
public := "public name"
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
InviteCode: "AbCdE",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: public},
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
Phone: "13800138000",
Secret: []byte(secret),
Cred: []MsgCredClient{{
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
Params: map[string]any{"challenge_id": "challenge-1"},
}},
Desc: &MsgSetDesc{Public: public},
},
}
@@ -1430,6 +1443,95 @@ func TestDispatchAccNew(t *testing.T) {
}
}
func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) {
ctrl := gomock.NewController(t)
ss := mock_store.NewMockPersistentStorageInterface(ctrl)
uu := mock_store.NewMockUsersPersistenceInterface(ctrl)
aa := mock_auth.NewMockAuthHandler(ctrl)
uid := types.Uid(2)
oldSecurity := store.Security
oldInvites := store.RegistrationInvites
oldAuthValidators := globals.authValidators
store.Store = ss
store.Users = uu
store.Security = &testSecurityStore{version: 1}
store.RegistrationInvites = nil
globals.authValidators = map[auth.Level][]string{}
setRegistrationSMSConfigForTest(true)
defer func() {
store.Store = nil
store.Users = nil
store.Security = oldSecurity
store.RegistrationInvites = oldInvites
globals.authValidators = oldAuthValidators
resetRegistrationSMSConfigForTest()
ctrl.Finish()
}()
remoteAddr := "192.168.0.1"
secret := "alice:password"
authRec := &auth.Rec{
TenantID: 1,
Uid: uid,
AuthLevel: auth.LevelAuth,
State: types.StateOK,
}
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil)
uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn(
func(_ types.TenantID, user *types.User, private any) (*types.User, error) {
if user.Username != "alice" || user.Phone != "" {
t.Fatalf("unexpected registration user: username=%q phone=%q", user.Username, user.Phone)
}
user.SetUid(uid)
return user, nil
})
aa.EXPECT().AddRecord(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, gomock.Any(),
[]byte(secret)).Return(authRec, nil)
ss.EXPECT().GetLogicalAuthHandler("token").Return(aa)
aa.EXPECT().GenSecret(gomock.Any()).Return([]byte("<==auth-token==>"), time.Now(), nil)
s := &Session{
send: make(chan any, 10),
authLvl: auth.LevelAuth,
ver: 16,
tenantID: 1,
tenantCode: "test",
remoteAddr: remoteAddr,
}
wg := sync.WaitGroup{}
r := responses{}
wg.Add(1)
go s.testWriteLoop(&r, &wg)
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "username-only",
User: "new",
Scheme: "basic",
Username: "alice",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
},
}
s.dispatch(msg)
close(s.send)
wg.Wait()
if len(r.messages) != 1 {
t.Fatalf("responses: expected 1, received %d", len(r.messages))
}
resp := r.messages[0].(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != 201 {
t.Fatalf("expected successful username registration, got %#v", resp.Ctrl)
}
if username := resp.Ctrl.Params.(map[string]any)["username"].(string); username != "alice" {
t.Fatalf("username: got %q", username)
}
}
func TestDispatchNoMessage(t *testing.T) {
remoteAddr := "192.168.0.1"
s := &Session{
+75 -2
View File
@@ -209,6 +209,9 @@ func (ss *SessionStore) DeliverToUser(tenantID types.TenantID, uid types.Uid, sk
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != tenantID || sess.uid != uid || sess.sid == skipSid {
return true
}
if userMessageSkipTopic(msg, sess) {
return true
}
if sess.queueOut(msg.copy()) {
delivered++
}
@@ -217,6 +220,37 @@ func (ss *SessionStore) DeliverToUser(tenantID types.TenantID, uid types.Uid, sk
return delivered
}
func userMessageSkipTopic(msg *ServerComMessage, sess *Session) bool {
if msg == nil || sess == nil {
return false
}
skipTopic := ""
switch {
case msg.Pres != nil:
skipTopic = msg.Pres.SkipTopic
case msg.Info != nil:
skipTopic = msg.Info.SkipTopic
}
return sessionHasTopicOrP2PAlias(sess, skipTopic)
}
func sessionHasTopicOrP2PAlias(sess *Session, topic string) bool {
if sess == nil || topic == "" {
return false
}
if sess.hasSub(topic) {
return true
}
if types.GetTopicCat(topic) != types.TopicCatP2P || sess.uid.IsZero() {
return false
}
viewTopic, err := types.P2PNameForUser(sess.uid, topic)
if err != nil || viewTopic == "" {
return false
}
return sess.hasSub(viewTopic)
}
func (ss *SessionStore) DeliverToTopicSubscribers(tenantID types.TenantID, topic, skipSid string, msg *ServerComMessage) int {
if ss == nil || !tenantID.IsValid() || topic == "" || msg == nil {
return 0
@@ -304,13 +338,16 @@ func (ss *SessionStore) Shutdown() {
}
// EvictUser terminates all sessions of a given user.
func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string) {
func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string, reason ...string) {
ss.lock.Lock()
defer ss.lock.Unlock()
// FIXME: this probably needs to be optimized. This may take very long time if the node hosts 100000 sessions.
evicted := NoErrEvicted("", "", types.TimeNow())
evicted.AsUser = uid.UserId()
if len(reason) > 0 && reason[0] != "" {
evicted.Ctrl.Params = map[string]any{"reason": reason[0]}
}
for _, s := range ss.sessCache {
if s.uid == uid && !s.isMultiplex() && s.sid != skipSid {
_, data := s.serialize(evicted)
@@ -326,7 +363,7 @@ func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string) {
}
// EvictUserDevice terminates all sessions of a given user connected with a device.
func (ss *SessionStore) EvictUserDevice(uid types.Uid, deviceID string) int {
func (ss *SessionStore) EvictUserDevice(uid types.Uid, deviceID string, reason ...string) int {
ss.lock.Lock()
defer ss.lock.Unlock()
@@ -335,6 +372,9 @@ func (ss *SessionStore) EvictUserDevice(uid types.Uid, deviceID string) int {
}
evicted := NoErrEvicted("", "", types.TimeNow())
evicted.AsUser = uid.UserId()
if len(reason) > 0 && reason[0] != "" {
evicted.Ctrl.Params = map[string]any{"reason": reason[0]}
}
count := 0
for _, s := range ss.sessCache {
if s.uid == uid && s.deviceID == deviceID && !s.isMultiplex() {
@@ -352,6 +392,39 @@ func (ss *SessionStore) EvictUserDevice(uid types.Uid, deviceID string) int {
return count
}
// EvictUserPlatform terminates all sessions of a given user in the same eviction
// platform group, keeping the session identified by skipSid (e.g. the new login
// which triggers the eviction). ios/android/mobile are treated as one mobile
// group (顶号互踢); web only matches web.
func (ss *SessionStore) EvictUserPlatform(uid types.Uid, platf, skipSid string, reason ...string) int {
ss.lock.Lock()
defer ss.lock.Unlock()
if uid.IsZero() || platf == "" {
return 0
}
evicted := NoErrEvicted("", "", types.TimeNow())
evicted.AsUser = uid.UserId()
if len(reason) > 0 && reason[0] != "" {
evicted.Ctrl.Params = map[string]any{"reason": reason[0]}
}
count := 0
for _, s := range ss.sessCache {
if s.uid == uid && sameEvictPlatform(s.platf, platf) && !s.isMultiplex() && s.sid != skipSid {
_, data := s.serialize(evicted)
s.stopSession(data)
delete(ss.sessCache, s.sid)
if s.proto == LPOLL {
ss.lru.Remove(s.lpTracker)
}
count++
}
}
statsSet("LiveSessions", int64(len(ss.sessCache)))
return count
}
// NodeRestarted removes stale sessions from a restarted cluster node.
// - nodeName is the name of affected node
// - fingerprint is the new fingerprint of the node.
+23
View File
@@ -61,9 +61,32 @@ var (
registrationSMSCache = newConfigCache[bool]()
thirdPartyConfigCache = newConfigCache[map[string]string]()
appReleaseConfigCache = newConfigCache[*types.AppRelease]()
moderationProviderCache = newConfigCache[*types.ModerationProviderConfig]()
moderationPolicyCache = newConfigCache[*types.ModerationPolicy]()
moderationWordsCache = newConfigCache[*types.SensitiveWordSet]()
thirdPartyConfigAllKeys = "*"
)
func InvalidateModerationConfigCache(tenantCode string, provider, policy, words bool) {
if provider {
moderationProviderCache.clear()
}
if policy {
if strings.TrimSpace(tenantCode) == "" {
moderationPolicyCache.clear()
} else {
moderationPolicyCache.delete(strings.TrimSpace(tenantCode))
}
}
if words {
if strings.TrimSpace(tenantCode) == "" {
moderationWordsCache.clear()
} else {
moderationWordsCache.delete(strings.TrimSpace(tenantCode))
}
}
}
func cloneTenant(t *types.Tenant) *types.Tenant {
if t == nil {
return nil
+343 -20
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
@@ -32,6 +33,14 @@ type redisConfig struct {
CacheTTL int `json:"cache_ttl_seconds"`
ShortTTL int `json:"short_state_ttl_seconds"`
GroupFanout bool `json:"group_fanout_enabled"`
// Sentinel-aware connection. When set, the runtime uses a failover client
// instead of the single Addr. Required whenever Redis runs behind Sentinel:
// a plain client pinned to one address will hit replicas after failover,
// where writes fail with READONLY and reads return stale values (a stale
// seq counter can hand out already-persisted seq values).
SentinelMasterName string `json:"sentinel_master_name"`
SentinelAddrs []string `json:"sentinel_addrs"`
}
type groupMessageUniqueRecord struct {
@@ -46,6 +55,14 @@ type MessageUniqueClaim struct {
Seq int64
}
// GroupMessageShardRoute describes the Seq range stored in one monthly table.
type GroupMessageShardRoute struct {
Month string `db:"month"`
MinSeq int64 `db:"min_seq"`
MaxSeq int64 `db:"max_seq"`
Count int64 `db:"count"`
}
type groupFanoutEnvelope struct {
OriginNodeID string `json:"origin_node_id"`
Message json.RawMessage `json:"message"`
@@ -58,15 +75,32 @@ type RealtimeFanoutEnvelope struct {
TargetType string `json:"target_type"`
Target string `json:"target"`
SkipSid string `json:"skip_sid,omitempty"`
SkipTopic string `json:"skip_topic,omitempty"`
DedupKey string `json:"dedup_key,omitempty"`
Message json.RawMessage `json:"message"`
}
type LoginSession struct {
UserID int64 `json:"user_id"`
TenantID int64 `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
DeviceID string `json:"device_id,omitempty"`
AuthLevel string `json:"auth_level"`
State string `json:"state"`
ExpiresAt string `json:"expires_at"`
IssuedAt string `json:"issued_at"`
}
type groupMessageRuntime interface {
Close() error
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error)
NextSeq(tenantID types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, error)
PeekSeq(tenantID types.TenantID, topic string) (int64, error)
BumpSeqFloor(tenantID types.TenantID, topic string, seq int64) error
MarkPending(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64) error
PutShardRoute(tenantID types.TenantID, topic string, route GroupMessageShardRoute) error
GetShardRoutes(tenantID types.TenantID, topic string) ([]GroupMessageShardRoute, bool, error)
DeleteShardRoutes(tenantID types.TenantID, topic string) error
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
@@ -95,6 +129,7 @@ type groupMessageRuntime interface {
ClaimShortState(namespace, id string, ttl time.Duration) (bool, error)
ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool, bool, error)
ShortStateExists(namespace, id string) (bool, error)
StoreLoginSession(token []byte, session LoginSession, ttl time.Duration) error
}
var groupMsgRuntime groupMessageRuntime
@@ -110,12 +145,73 @@ func ClaimMessageUnique(tenantID types.TenantID, topic string, from types.Uid, c
return &MessageUniqueClaim{Status: rec.Status, Seq: rec.Seq}, claimed, true, err
}
func ReserveMessageSeq(tenantID types.TenantID, topic string, baseline int64) (int64, bool, error) {
func ReserveMessageSeq(tenantID types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return 0, false, nil
return 0, candidateMonth, false, nil
}
seq, err := groupMsgRuntime.NextSeq(tenantID, topic, baseline)
return seq, true, err
seq, month, err := nextMessageSeqWithFloor(tenantID, topic, baseline, candidateMonth)
return seq, month, true, err
}
func PutGroupMessageShardRoute(tenantID types.TenantID, topic string, route GroupMessageShardRoute) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || route.Month == "" {
return nil
}
return groupMsgRuntime.PutShardRoute(tenantID, topic, route)
}
func GetGroupMessageShardRoutes(tenantID types.TenantID, topic string) ([]GroupMessageShardRoute, bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil, false, nil
}
return groupMsgRuntime.GetShardRoutes(tenantID, topic)
}
func DeleteGroupMessageShardRoutes(tenantID types.TenantID, topic string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil
}
return groupMsgRuntime.DeleteShardRoutes(tenantID, topic)
}
// TopicLastSeqID returns the seq last persisted for the topic in the DB, or 0
// when the topic is unknown. It is used as a floor when re-seeding the Redis
// seq counter.
func TopicLastSeqID(tenantID types.TenantID, topic string) int64 {
if Topics == nil || !tenantID.IsValid() || topic == "" {
return 0
}
stopic, err := Topics.Get(tenantID, topic)
if err != nil || stopic == nil {
return 0
}
return stopic.SeqId
}
// nextMessageSeqWithFloor allocates the next seq for the topic. When the Redis
// counter key is missing (Redis restart, eviction or flush) it re-seeds from
// the DB topic seq instead of trusting the caller's in-memory baseline, which
// may be stale and would otherwise hand out already-persisted seq values.
func nextMessageSeqWithFloor(tenantID types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, error) {
if baseline < 0 {
baseline = 0
}
if current, err := groupMsgRuntime.PeekSeq(tenantID, topic); err == nil && current <= 0 {
if floor := TopicLastSeqID(tenantID, topic); floor > baseline {
baseline = floor
}
}
return groupMsgRuntime.NextSeq(tenantID, topic, baseline, candidateMonth)
}
// BumpMessageSeqFloor pushes the Redis seq counter forward to seq (never
// backward). Used after the async persister re-allocates a seq following a
// uniqueness conflict so subsequent allocations do not collide again.
func BumpMessageSeqFloor(tenantID types.TenantID, topic string, seq int64) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || seq <= 0 {
return nil
}
return groupMsgRuntime.BumpSeqFloor(tenantID, topic, seq)
}
func MarkMessageUniqueDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
@@ -190,6 +286,13 @@ func IsLoginTokenRevoked(tenantID types.TenantID, token []byte) (bool, bool, err
return revoked, true, err
}
func StoreLoginSession(token []byte, session LoginSession, ttl time.Duration) (bool, error) {
if groupMsgRuntime == nil || len(token) == 0 || ttl <= 0 {
return false, nil
}
return true, groupMsgRuntime.StoreLoginSession(token, session, ttl)
}
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
@@ -388,15 +491,31 @@ func initGroupMessageRuntime(raw json.RawMessage) error {
dialTimeout := timeoutOrDefault(cfg.DialMS, 2*time.Second)
readTimeout := timeoutOrDefault(cfg.ReadMS, 2*time.Second)
writeTimeout := timeoutOrDefault(cfg.WriteMS, 2*time.Second)
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
})
var client redis.UniversalClient
if cfg.SentinelMasterName != "" && len(cfg.SentinelAddrs) > 0 {
client = redis.NewFailoverClient(&redis.FailoverOptions{
MasterName: cfg.SentinelMasterName,
SentinelAddrs: cfg.SentinelAddrs,
SentinelPassword: cfg.Password,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
})
} else {
client = redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
})
}
ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
@@ -422,7 +541,7 @@ func timeoutOrDefault(ms int, fallback time.Duration) time.Duration {
}
type redisGroupMessageRuntime struct {
client *redis.Client
client redis.UniversalClient
prefix string
cfg redisConfig
}
@@ -470,26 +589,160 @@ func (r *redisGroupMessageRuntime) Claim(tenantID types.TenantID, topic string,
return &current, false, nil
}
func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) {
func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, error) {
if baseline < 0 {
baseline = 0
}
redisNow, err := r.client.Time(context.Background()).Result()
if err != nil {
return 0, "", err
}
candidateMonth = redisNow.UTC().Format("200601")
if !validShardMonth(candidateMonth) {
return 0, "", types.ErrMalformed
}
seqKey := r.seqKey(tenantID, topic)
seq, err := r.client.Eval(context.Background(), `
result, err := r.client.Eval(context.Background(), `
local current = redis.call("GET", KEYS[1])
if not current then
redis.call("SET", KEYS[1], ARGV[1])
elseif tonumber(current) < tonumber(ARGV[1]) then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCR", KEYS[1])
`, []string{seqKey}, baseline).Int64()
local active_month = redis.call("GET", KEYS[2])
if not active_month or active_month < ARGV[2] then
redis.call("SET", KEYS[2], ARGV[2])
active_month = ARGV[2]
end
local seq = redis.call("INCR", KEYS[1])
return {seq, active_month}
`, []string{seqKey, r.activeMonthKey(tenantID)}, baseline, candidateMonth).Slice()
if err != nil {
return 0, "", err
}
if len(result) != 2 {
return 0, "", fmt.Errorf("redis group message sequence returned %d values", len(result))
}
seq, err := strconv.ParseInt(fmt.Sprint(result[0]), 10, 64)
if err != nil {
return 0, "", err
}
month := fmt.Sprint(result[1])
if !validShardMonth(month) {
return 0, "", fmt.Errorf("redis returned invalid active month %q", month)
}
return seq, month, nil
}
func (r *redisGroupMessageRuntime) PutShardRoute(tenantID types.TenantID, topic string, route GroupMessageShardRoute) error {
if !validShardMonth(route.Month) || route.MinSeq <= 0 || route.MaxSeq < route.MinSeq {
return types.ErrMalformed
}
_, err := r.client.Eval(context.Background(), `
local min_seq = tonumber(ARGV[2])
local max_seq = tonumber(ARGV[3])
local count = tonumber(ARGV[4])
local meta = redis.call("HGET", KEYS[2], ARGV[1])
if meta then
local old_min, old_max, old_count = string.match(meta, "^(%d+):(%d+):(%d+)$")
if old_min then
min_seq = math.min(min_seq, tonumber(old_min))
max_seq = math.max(max_seq, tonumber(old_max))
if count == 1 then
count = tonumber(old_count) + 1
else
count = math.max(count, tonumber(old_count))
end
end
end
redis.call("ZADD", KEYS[1], min_seq, ARGV[1])
redis.call("HSET", KEYS[2], ARGV[1], min_seq .. ":" .. max_seq .. ":" .. count)
return 1
`, []string{r.shardRouteKey(tenantID, topic), r.shardRouteMetaKey(tenantID, topic)},
route.Month, route.MinSeq, route.MaxSeq, route.Count).Result()
return err
}
func (r *redisGroupMessageRuntime) GetShardRoutes(tenantID types.TenantID, topic string) ([]GroupMessageShardRoute, bool, error) {
ctx := context.Background()
months, err := r.client.ZRevRange(ctx, r.shardRouteKey(tenantID, topic), 0, -1).Result()
if err != nil {
return nil, false, err
}
if len(months) == 0 {
return nil, false, nil
}
values, err := r.client.HMGet(ctx, r.shardRouteMetaKey(tenantID, topic), months...).Result()
if err != nil {
return nil, false, err
}
routes := make([]GroupMessageShardRoute, 0, len(months))
for i, month := range months {
if values[i] == nil {
continue
}
parts := strings.Split(fmt.Sprint(values[i]), ":")
if len(parts) != 3 {
continue
}
minSeq, minErr := strconv.ParseInt(parts[0], 10, 64)
maxSeq, maxErr := strconv.ParseInt(parts[1], 10, 64)
count, countErr := strconv.ParseInt(parts[2], 10, 64)
if minErr == nil && maxErr == nil && countErr == nil && validShardMonth(month) {
routes = append(routes, GroupMessageShardRoute{Month: month, MinSeq: minSeq, MaxSeq: maxSeq, Count: count})
}
}
return routes, len(routes) > 0, nil
}
func (r *redisGroupMessageRuntime) DeleteShardRoutes(tenantID types.TenantID, topic string) error {
return r.client.Del(context.Background(), r.shardRouteKey(tenantID, topic), r.shardRouteMetaKey(tenantID, topic)).Err()
}
func validShardMonth(month string) bool {
if len(month) != 6 {
return false
}
for i := range month {
if month[i] < '0' || month[i] > '9' {
return false
}
}
parsed, err := time.Parse("200601", month)
return err == nil && parsed.Format("200601") == month
}
func (r *redisGroupMessageRuntime) PeekSeq(tenantID types.TenantID, topic string) (int64, error) {
value, err := r.client.Get(context.Background(), r.seqKey(tenantID, topic)).Result()
if err == redis.Nil {
return 0, nil
}
if err != nil {
return 0, err
}
seq, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
return seq, nil
}
func (r *redisGroupMessageRuntime) BumpSeqFloor(tenantID types.TenantID, topic string, seq int64) error {
if seq <= 0 {
return nil
}
_, err := r.client.Eval(context.Background(), `
local current = redis.call("GET", KEYS[1])
if not current or tonumber(current) < tonumber(ARGV[1]) then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("GET", KEYS[1])
`, []string{r.seqKey(tenantID, topic)}, seq).Int64()
return err
}
func (r *redisGroupMessageRuntime) MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
if clientMsgID == "" {
return nil
@@ -809,6 +1062,7 @@ func (r *redisGroupMessageRuntime) PublishRealtimeFanout(originNodeID string, te
TargetType: targetType,
Target: target,
SkipSid: skipSid,
SkipTopic: realtimeFanoutSkipTopic(message),
DedupKey: dedupKey,
Message: payload,
})
@@ -818,6 +1072,39 @@ func (r *redisGroupMessageRuntime) PublishRealtimeFanout(originNodeID string, te
return r.client.Publish(context.Background(), r.realtimeFanoutChannel(tenantID), envelope).Err()
}
func realtimeFanoutSkipTopic(message any) string {
if message == nil {
return ""
}
v := reflect.ValueOf(message)
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return ""
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return ""
}
for _, fieldName := range []string{"Pres", "Info"} {
field := v.FieldByName(fieldName)
if !field.IsValid() || field.IsNil() {
continue
}
if field.Kind() == reflect.Pointer {
field = field.Elem()
}
if field.Kind() != reflect.Struct {
continue
}
skip := field.FieldByName("SkipTopic")
if skip.IsValid() && skip.Kind() == reflect.String {
return strings.TrimSpace(skip.String())
}
}
return ""
}
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
return r.StartRealtimeFanoutSubscriber(ctx, nodeID, func(envelope *RealtimeFanoutEnvelope) {
if envelope != nil {
@@ -990,6 +1277,18 @@ func (r *redisGroupMessageRuntime) ShortStateExists(namespace, id string) (bool,
return count > 0, err
}
func (r *redisGroupMessageRuntime) StoreLoginSession(token []byte, session LoginSession, ttl time.Duration) error {
if len(token) == 0 || ttl <= 0 {
return nil
}
digest := sha256.Sum256(token)
payload, err := json.Marshal(session)
if err != nil {
return err
}
return r.client.Set(context.Background(), r.loginSessionKey(hex.EncodeToString(digest[:])), payload, ttl).Err()
}
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
if opts != nil && len(opts.IdRanges) > 0 {
seqs := expandRanges(opts.IdRanges, limit)
@@ -1056,7 +1355,19 @@ func expandRanges(ranges []types.Range, limit int) []int64 {
}
func (r *redisGroupMessageRuntime) seqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "last_seq")
return r.shardKey(tenantID, "topic", topic, "last_seq")
}
func (r *redisGroupMessageRuntime) activeMonthKey(tenantID types.TenantID) string {
return r.shardKey(tenantID, "groupmsg", "active_month")
}
func (r *redisGroupMessageRuntime) shardRouteKey(tenantID types.TenantID, topic string) string {
return r.shardKey(tenantID, "groupmsg", "route", topic)
}
func (r *redisGroupMessageRuntime) shardRouteMetaKey(tenantID types.TenantID, topic string) string {
return r.shardKey(tenantID, "groupmsg", "route_meta", topic)
}
func (r *redisGroupMessageRuntime) uniqueKey(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) string {
@@ -1133,6 +1444,10 @@ func (r *redisGroupMessageRuntime) shortStateKey(namespace, id string) string {
return r.key(types.ZeroTenantID, "short", namespace, id)
}
func (r *redisGroupMessageRuntime) loginSessionKey(tokenHash string) string {
return strings.TrimSuffix(r.prefix, ":") + ":session:token:" + tokenHash
}
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
ttl := r.cfg.CacheTTL
if namespace == "verification" || namespace == "action_token" {
@@ -1146,3 +1461,11 @@ func (r *redisGroupMessageRuntime) key(tenantID types.TenantID, parts ...string)
all = append(all, parts...)
return strings.Join(all, ":")
}
func (r *redisGroupMessageRuntime) shardKey(tenantID types.TenantID, parts ...string) string {
prefix := strings.TrimSuffix(r.prefix, ":")
slot := "{" + strconv.FormatInt(int64(tenantID), 10) + "}"
all := []string{prefix, slot}
all = append(all, parts...)
return strings.Join(all, ":")
}
+145 -12
View File
@@ -2,6 +2,9 @@ package store
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"time"
@@ -12,7 +15,9 @@ type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord
seqs map[string]int64
shortStates map[string]bool
objects map[string][]byte
deletedPrefixes []string
deletedObjects []string
}
func newTestGroupMessageRuntime() *testGroupMessageRuntime {
@@ -20,6 +25,7 @@ func newTestGroupMessageRuntime() *testGroupMessageRuntime {
claims: make(map[string]*groupMessageUniqueRecord),
seqs: make(map[string]int64),
shortStates: make(map[string]bool),
objects: make(map[string][]byte),
}
}
@@ -35,14 +41,37 @@ func (r *testGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, f
return rec, true, nil
}
func (r *testGroupMessageRuntime) NextSeq(_ types.TenantID, topic string, baseline int64) (int64, error) {
func (r *testGroupMessageRuntime) NextSeq(_ types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, error) {
if r.seqs[topic] < baseline {
r.seqs[topic] = baseline
}
r.seqs[topic]++
return r.seqs[topic], candidateMonth, nil
}
func (r *testGroupMessageRuntime) PutShardRoute(types.TenantID, string, GroupMessageShardRoute) error {
return nil
}
func (r *testGroupMessageRuntime) GetShardRoutes(types.TenantID, string) ([]GroupMessageShardRoute, bool, error) {
return nil, false, nil
}
func (r *testGroupMessageRuntime) DeleteShardRoutes(types.TenantID, string) error {
return nil
}
func (r *testGroupMessageRuntime) PeekSeq(_ types.TenantID, topic string) (int64, error) {
return r.seqs[topic], nil
}
func (r *testGroupMessageRuntime) BumpSeqFloor(_ types.TenantID, topic string, seq int64) error {
if r.seqs[topic] < seq {
r.seqs[topic] = seq
}
return nil
}
func (r *testGroupMessageRuntime) MarkDone(_ types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
key := topic + ":" + from.UserId() + ":" + clientMsgID
r.claims[key] = &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()}
@@ -121,20 +150,37 @@ func (r *testGroupMessageRuntime) StartRealtimeFanoutSubscriber(context.Context,
return nil
}
func (r *testGroupMessageRuntime) CacheObject(types.TenantID, string, string, any) error {
func (r *testGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
r.objects[strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id] = payload
return nil
}
func (r *testGroupMessageRuntime) GetCachedObject(types.TenantID, string, string, any) (bool, error) {
return false, nil
func (r *testGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
payload, ok := r.objects[strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id]
if !ok {
return false, nil
}
return true, json.Unmarshal(payload, value)
}
func (r *testGroupMessageRuntime) DeleteCachedObject(types.TenantID, string, string) error {
func (r *testGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
r.deletedObjects = append(r.deletedObjects, namespace+":"+id)
delete(r.objects, strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id)
return nil
}
func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(_ types.TenantID, _ string, prefix string) error {
func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, prefix string) error {
r.deletedPrefixes = append(r.deletedPrefixes, prefix)
cachePrefix := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + prefix
for key := range r.objects {
if strings.HasPrefix(key, cachePrefix) {
delete(r.objects, key)
}
}
return nil
}
@@ -171,6 +217,10 @@ func (r *testGroupMessageRuntime) ShortStateExists(namespace, id string) (bool,
return r.shortStates[namespace+":"+id], nil
}
func (r *testGroupMessageRuntime) StoreLoginSession([]byte, LoginSession, time.Duration) error {
return nil
}
func TestLoginTokenRevocation(t *testing.T) {
oldRuntime := groupMsgRuntime
groupMsgRuntime = newTestGroupMessageRuntime()
@@ -191,6 +241,46 @@ func TestLoginTokenRevocation(t *testing.T) {
}
}
func TestGroupMemberPageCacheRoundTripAndInvalidate(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()
groupMsgRuntime = runtime
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
opts := types.GroupMemberListOptions{
Role: "member",
Query: " alice ",
Cursor: "42",
Limit: 20,
Mentionable: true,
}
key := groupMemberPageCacheID("grpAlpha", opts)
if !strings.HasPrefix(key, "grpAlpha|") {
t.Fatalf("cache key must be topic-prefixed for invalidation: %q", key)
}
CacheGroupMemberPage(7, key, groupMemberPageCache{
Items: []types.GroupMember{{
ID: 43,
Topic: "grpAlpha",
UserID: types.Uid(100),
Role: types.GroupMemberRoleMember,
}},
NextCursor: "43",
})
cached, ok := GetCachedGroupMemberPage(7, key)
if !ok || cached.NextCursor != "43" || len(cached.Items) != 1 || cached.Items[0].UserID != types.Uid(100) {
t.Fatalf("unexpected cached page: ok=%v page=%#v", ok, cached)
}
invalidateGroupMemberPages(7, "grpAlpha")
if _, ok := GetCachedGroupMemberPage(7, key); ok {
t.Fatalf("group member page cache survived invalidation")
}
if len(runtime.deletedPrefixes) != 1 || runtime.deletedPrefixes[0] != "grpAlpha|" {
t.Fatalf("unexpected invalidation prefixes: %#v", runtime.deletedPrefixes)
}
}
func TestRealtimeFanoutDedupClaimIDIsNodeScoped(t *testing.T) {
tenantID := types.TenantID(7)
dedupKey := "data:p2pABC:42"
@@ -219,9 +309,9 @@ func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
if err != nil || !ready || !claimed || claim == nil || claim.Status != "pending" {
t.Fatalf("unexpected first claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err)
}
seq, ready, err := ReserveMessageSeq(tenantID, topic, 184)
if err != nil || !ready || seq != 185 {
t.Fatalf("unexpected first seq: seq=%d ready=%v err=%v", seq, ready, err)
seq, month, ready, err := ReserveMessageSeq(tenantID, topic, 184, "202609")
if err != nil || !ready || seq != 185 || month != "202609" {
t.Fatalf("unexpected first seq: seq=%d month=%s ready=%v err=%v", seq, month, ready, err)
}
if err = MarkMessageUniqueDone(tenantID, topic, from, "mid-1", seq, ""); err != nil {
t.Fatalf("mark done: %v", err)
@@ -236,9 +326,40 @@ func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
if err != nil || !ready || !claimed || claim == nil {
t.Fatalf("unexpected second claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err)
}
seq, ready, err = ReserveMessageSeq(tenantID, topic, 184)
if err != nil || !ready || seq != 186 {
t.Fatalf("unexpected second seq: seq=%d ready=%v err=%v", seq, ready, err)
seq, month, ready, err = ReserveMessageSeq(tenantID, topic, 184, "202609")
if err != nil || !ready || seq != 186 || month != "202609" {
t.Fatalf("unexpected second seq: seq=%d month=%s ready=%v err=%v", seq, month, ready, err)
}
}
func TestBumpMessageSeqFloorNeverMovesBackward(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()
groupMsgRuntime = runtime
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
tenantID := types.TenantID(7)
topic := types.Uid(1).P2PName(types.Uid(2))
seq, _, ready, err := ReserveMessageSeq(tenantID, topic, 0, "")
if err != nil || !ready || seq != 1 {
t.Fatalf("unexpected first seq: seq=%d ready=%v err=%v", seq, ready, err)
}
if err = BumpMessageSeqFloor(tenantID, topic, 5); err != nil {
t.Fatalf("bump floor: %v", err)
}
if current, err := runtime.PeekSeq(tenantID, topic); err != nil || current != 5 {
t.Fatalf("floor was not applied: current=%d err=%v", current, err)
}
if err = BumpMessageSeqFloor(tenantID, topic, 3); err != nil {
t.Fatalf("lower bump: %v", err)
}
if current, err := runtime.PeekSeq(tenantID, topic); err != nil || current != 5 {
t.Fatalf("floor must never move backward: current=%d err=%v", current, err)
}
// The next allocation must continue above the bumped floor.
if seq, _, _, err := ReserveMessageSeq(tenantID, topic, 0, ""); err != nil || seq != 6 {
t.Fatalf("unexpected seq after bump: seq=%d err=%v", seq, err)
}
}
@@ -253,3 +374,15 @@ func TestInvalidateGroupMemberPagesUsesTopicKeyPrefix(t *testing.T) {
t.Fatalf("unexpected deleted prefixes: %#v", runtime.deletedPrefixes)
}
}
func TestInvalidateGroupSettingsUsesTopicCacheKey(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()
groupMsgRuntime = runtime
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
invalidateGroupSettings(7, " grpAlpha ")
if len(runtime.deletedObjects) != 1 || runtime.deletedObjects[0] != "group_settings:grpAlpha" {
t.Fatalf("unexpected deleted objects: %#v", runtime.deletedObjects)
}
}
+33
View File
@@ -1008,6 +1008,21 @@ func (mr *MockTopicsPersistenceInterfaceMockRecorder) OwnerChange(tenantID, topi
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OwnerChange", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).OwnerChange), tenantID, topic, newOwner)
}
// BumpMutationVersion mocks base method.
func (m *MockTopicsPersistenceInterface) BumpMutationVersion(tenantID types.TenantID, topic string, at time.Time) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BumpMutationVersion", tenantID, topic, at)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// BumpMutationVersion indicates an expected call of BumpMutationVersion.
func (mr *MockTopicsPersistenceInterfaceMockRecorder) BumpMutationVersion(tenantID, topic, at interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BumpMutationVersion", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).BumpMutationVersion), tenantID, topic, at)
}
// Update mocks base method.
func (m *MockTopicsPersistenceInterface) Update(tenantID types.TenantID, topic string, update map[string]any) error {
m.ctrl.T.Helper()
@@ -1220,6 +1235,24 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Search), tenantID, topic, forUser, query, messageType, cursor, limit)
}
// Mutations mocks base method.
func (m *MockMessagesPersistenceInterface) Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Mutations", tenantID, topic, forUser, sinceVersion, limit)
ret0, _ := ret[0].([]types.MessageMutation)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(bool)
ret3, _ := ret[3].(int64)
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// Mutations indicates an expected call of Mutations.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Mutations(tenantID, topic, forUser, sinceVersion, limit interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Mutations", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Mutations), tenantID, topic, forUser, sinceVersion, limit)
}
// GetOne mocks base method.
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) {
m.ctrl.T.Helper()
+9
View File
@@ -12,6 +12,15 @@ type SecurityAuditPersistenceInterface interface {
RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error
CloseLogin(types.TenantID, string, time.Time) error
ApplyMessageAction(types.TenantID, *types.MessageAdminAction) error
RecordContentModeration(types.TenantID, *types.ContentModerationLog) error
}
func (securityAuditMapper) RecordContentModeration(tenantID types.TenantID, event *types.ContentModerationLog) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantContentModerationLogCreate(tenantID, event)
}
type securityAuditMapper struct{}
+258 -34
View File
@@ -170,6 +170,9 @@ type AdminInternalConfigProvider interface {
RegistrationSMSEnabled() (bool, error)
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
LatestAppRelease(platform, channel string) (*types.AppRelease, error)
ModerationProvider() (*types.ModerationProviderConfig, error)
ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error)
SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error)
}
var adminInternalConfigProvider AdminInternalConfigProvider
@@ -178,6 +181,60 @@ func SetAdminInternalConfigProvider(provider AdminInternalConfigProvider) {
adminInternalConfigProvider = provider
}
type ModerationConfigPersistenceInterface interface {
Provider() (*types.ModerationProviderConfig, error)
Policy(tenantCode string) (*types.ModerationPolicy, error)
Words(tenantCode string) (*types.SensitiveWordSet, error)
}
type moderationConfigMapper struct{}
var ModerationConfig ModerationConfigPersistenceInterface = moderationConfigMapper{}
func (moderationConfigMapper) Provider() (*types.ModerationProviderConfig, error) {
if value, ok := moderationProviderCache.get("provider"); ok {
return value, nil
}
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
value, err := adminInternalConfigProvider.ModerationProvider()
if err == nil && value != nil {
moderationProviderCache.set("provider", value)
}
return value, err
}
func (moderationConfigMapper) Policy(tenantCode string) (*types.ModerationPolicy, error) {
tenantCode = strings.TrimSpace(tenantCode)
if value, ok := moderationPolicyCache.get(tenantCode); ok {
return value, nil
}
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
value, err := adminInternalConfigProvider.ModerationPolicy(tenantCode)
if err == nil && value != nil {
moderationPolicyCache.set(tenantCode, value)
}
return value, err
}
func (moderationConfigMapper) Words(tenantCode string) (*types.SensitiveWordSet, error) {
tenantCode = strings.TrimSpace(tenantCode)
if value, ok := moderationWordsCache.get(tenantCode); ok {
return value, nil
}
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
value, err := adminInternalConfigProvider.SensitiveWords(tenantCode)
if err == nil && value != nil {
moderationWordsCache.set(tenantCode, value)
}
return value, err
}
// RegistrationConfigPersistenceInterface exposes global registration policy.
type RegistrationConfigPersistenceInterface interface {
SMSEnabled() (bool, error)
@@ -1079,6 +1136,7 @@ type TopicsPersistenceInterface interface {
GetSubs(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
GetSubsAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
Update(tenantID types.TenantID, topic string, update map[string]any) error
BumpMutationVersion(tenantID types.TenantID, topic string, at time.Time) (int64, error)
UpdateSubCnt(tenantID types.TenantID, topic string) error
OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error
Delete(tenantID types.TenantID, topic string, isChan, hard bool) error
@@ -1235,6 +1293,18 @@ func (topicsMapper) Update(tenantID types.TenantID, topic string, update map[str
return nil
}
func (topicsMapper) BumpMutationVersion(tenantID types.TenantID, topic string, at time.Time) (int64, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
version, err := tenantAdp.TenantTopicBumpMutationVersion(tenantID, topic, at)
if err == nil {
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic)
}
return version, err
}
// OwnerChange replaces the old topic owner with the new owner.
func (topicsMapper) OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1258,6 +1328,9 @@ func (topicsMapper) Delete(tenantID types.TenantID, topic string, isChan, hard b
return err
}
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic)
if hard && types.GetTopicCat(topic) == types.TopicCatGrp {
_ = DeleteGroupMessageShardRoutes(tenantID, topic)
}
return nil
}
@@ -1386,6 +1459,7 @@ type MessagesPersistenceInterface interface {
GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error)
GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)
Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error)
Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error)
}
// messagesMapper is a concrete type implementing MessagesPersistenceInterface.
@@ -1635,6 +1709,7 @@ type GroupBusinessPersistenceInterface interface {
PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error)
ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
GetMute(types.TenantID, string, types.Uid) (*types.GroupMute, error)
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
ListFiles(types.TenantID, string, string, string, string, int) ([]types.GroupFile, string, error)
@@ -1685,6 +1760,9 @@ func (groupBusinessMapper) GetSettings(tenantID types.TenantID, topic string) (*
var cached types.GroupSettings
if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupSettings, topic, &cached); err != nil {
logs.Warn.Printf("cache group settings[%s]: read failed: %v", topic, err)
if delErr := DeleteCachedObject(tenantID, cacheNamespaceGroupSettings, topic); delErr != nil {
logs.Warn.Printf("cache group settings[%s]: delete corrupted cache failed: %v", topic, delErr)
}
} else if ok {
return &cached, nil
}
@@ -1743,6 +1821,11 @@ func (groupBusinessMapper) Dissolve(tenantID types.TenantID, topic string, actor
invalidateGroupMemberPages(tenantID, topic)
invalidateGroupMemberObjects(tenantID, topic)
invalidateTopicSubscriptions(tenantID, topic)
// The dissolve flips im_topics.state to StateDeleted. The cached topic
// object must be dropped too, otherwise a later Topics.Get (e.g. from
// initTopicGrp when a former member re-subscribes) would restore the
// dissolved group from the stale cache.
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, strings.TrimSpace(topic))
}
return settings, err
}
@@ -1794,15 +1877,51 @@ func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, op
if opts.Limit <= 0 || opts.Limit > 100 {
opts.Limit = 30
}
topic = strings.TrimSpace(topic)
opts.Role = strings.TrimSpace(opts.Role)
opts.EligibleRole = strings.TrimSpace(opts.EligibleRole)
opts.Query = strings.TrimSpace(opts.Query)
opts.Cursor = strings.TrimSpace(opts.Cursor)
cacheKey := groupMemberPageCacheID(topic, opts)
if cached, ok := GetCachedGroupMemberPage(tenantID, cacheKey); ok {
return cached.Items, cached.NextCursor, nil
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantGroupMemberList(tenantID, strings.TrimSpace(topic), opts)
members, nextCursor, err := tenantAdp.TenantGroupMemberList(tenantID, topic, opts)
if err != nil {
return nil, "", err
}
CacheGroupMemberPage(tenantID, cacheKey, groupMemberPageCache{
Items: members,
NextCursor: nextCursor,
})
return members, nextCursor, nil
}
func CacheGroupMemberPage(tenantID types.TenantID, key string, payload map[string]any) {
if strings.TrimSpace(key) == "" || payload == nil {
type groupMemberPageCache struct {
Items []types.GroupMember `json:"items"`
NextCursor string `json:"next_cursor"`
}
func groupMemberPageCacheID(topic string, opts types.GroupMemberListOptions) string {
limit := opts.Limit
if limit <= 0 || limit > 100 {
limit = 30
}
return strings.TrimSpace(topic) +
"|role=" + strconv.Quote(strings.TrimSpace(opts.Role)) +
"|eligible_role=" + strconv.Quote(strings.TrimSpace(opts.EligibleRole)) +
"|query=" + strconv.Quote(strings.TrimSpace(opts.Query)) +
"|cursor=" + strconv.Quote(strings.TrimSpace(opts.Cursor)) +
"|limit=" + strconv.Itoa(limit) +
"|mentionable=" + strconv.FormatBool(opts.Mentionable)
}
func CacheGroupMemberPage(tenantID types.TenantID, key string, payload groupMemberPageCache) {
if strings.TrimSpace(key) == "" {
return
}
if err := CacheObject(tenantID, cacheNamespaceGroupPage, strings.TrimSpace(key), payload); err != nil {
@@ -1810,17 +1929,17 @@ func CacheGroupMemberPage(tenantID types.TenantID, key string, payload map[strin
}
}
func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (map[string]any, bool) {
func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (groupMemberPageCache, bool) {
if strings.TrimSpace(key) == "" {
return nil, false
return groupMemberPageCache{}, false
}
var payload map[string]any
var payload groupMemberPageCache
if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupPage, strings.TrimSpace(key), &payload); err != nil {
logs.Warn.Printf("cache group member page[%s]: read failed: %v", key, err)
} else if ok {
return payload, true
}
return nil, false
return groupMemberPageCache{}, false
}
func invalidateGroupMemberPages(tenantID types.TenantID, topic string) {
@@ -1833,6 +1952,16 @@ func invalidateGroupMemberPages(tenantID types.TenantID, topic string) {
}
}
func invalidateGroupSettings(tenantID types.TenantID, topic string) {
topic = strings.TrimSpace(topic)
if topic == "" {
return
}
if err := DeleteCachedObject(tenantID, cacheNamespaceGroupSettings, topic); err != nil {
logs.Warn.Printf("cache group settings[%s]: delete failed: %v", topic, err)
}
}
func invalidateGroupMemberObjects(tenantID types.TenantID, topic string) {
topic = strings.TrimSpace(topic)
if topic == "" {
@@ -1900,6 +2029,7 @@ func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationI
invitation, err := tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
if err == nil && invitation != nil {
invalidateGroupMemberPages(tenantID, invitation.Topic)
invalidateGroupSettings(tenantID, invitation.Topic)
}
return invitation, err
}
@@ -1940,6 +2070,7 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re
resolved, err := tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
if err == nil && resolved != nil && resolved.Status == types.GroupJoinRequestStatusAccepted {
invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
}
return resolved, err
}
@@ -2002,6 +2133,17 @@ func (groupBusinessMapper) ListMutes(tenantID types.TenantID, topic, cursor stri
return tenantAdp.TenantGroupMuteList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(cursor), limit)
}
func (groupBusinessMapper) GetMute(tenantID types.TenantID, topic string, uid types.Uid) (*types.GroupMute, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupMuteGet(tenantID, strings.TrimSpace(topic), uid)
}
func (groupBusinessMapper) UpsertMute(tenantID types.TenantID, mute *types.GroupMute) (*types.GroupMute, error) {
if mute == nil || mute.Topic == "" || mute.UserID.IsZero() || mute.MutedBy.IsZero() {
return nil, types.ErrMalformed
@@ -2010,7 +2152,11 @@ func (groupBusinessMapper) UpsertMute(tenantID types.TenantID, mute *types.Group
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupMuteUpsert(tenantID, mute)
updated, err := tenantAdp.TenantGroupMuteUpsert(tenantID, mute)
if err == nil {
invalidateGroupMemberPages(tenantID, mute.Topic)
}
return updated, err
}
func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid, actor types.Uid, now time.Time) error {
@@ -2021,7 +2167,11 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid
if err != nil {
return err
}
return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now)
if err := tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now); err != nil {
return err
}
invalidateGroupMemberPages(tenantID, topic)
return nil
}
func (groupBusinessMapper) ListFiles(tenantID types.TenantID, topic, fileType, query, cursor string, limit int) ([]types.GroupFile, string, error) {
@@ -2071,8 +2221,9 @@ func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, i
}
created, err := tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
if err == nil {
if err == nil && created {
invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
}
return created, err
}
@@ -2092,6 +2243,7 @@ func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid type
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true))
invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
return nil
}
@@ -2132,7 +2284,11 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, uid, true))
}
// im_topics.owner is updated by the transfer; drop the cached topic
// object so a later Topics.Get sees the new owner.
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic)
invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
return nil
}
@@ -3000,9 +3156,8 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi
if item.Friendship.Topic == "" || item.Friendship.PeerID.IsZero() {
continue
}
if err := Subs.Update(tenantID, item.Friendship.Topic, item.Friendship.PeerID, map[string]any{"Public": public}); err != nil {
logs.Warn.Printf("profile %s: refresh subscription %s/%s public failed: %v", uid, item.Friendship.Topic, item.Friendship.PeerID, err)
}
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(item.Friendship.Topic, item.Friendship.PeerID, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(item.Friendship.Topic, item.Friendship.PeerID, true))
}
if nextCursor == "" {
break
@@ -3436,6 +3591,9 @@ func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types
if err != nil {
return nil, err
}
if peer == nil {
return nil, types.ErrNotFound
}
ctx.Peer = peer
if cachedBlock, err := cachedBlockContains(tenantID, userID, peerID); err != nil {
logs.Warn.Printf("cache block[%s:%s]: read failed: %v", userID.UserId(), peerID.UserId(), err)
@@ -3578,6 +3736,7 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
return nil, err
}
msg.ClientMsgID = clientMsgID
msg.InitTimes()
fromUid := types.ParseUid(msg.From)
isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp
if isGroupMessage && groupMsgRuntime != nil {
@@ -3594,7 +3753,7 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
return nil, types.ErrDuplicate
}
}
seq, err := groupMsgRuntime.NextSeq(tenantID, msg.Topic, msg.SeqId-1)
seq, shardMonth, err := nextMessageSeqWithFloor(tenantID, msg.Topic, msg.SeqId-1, msg.CreatedAt.UTC().Format("200601"))
if err != nil {
if clientMsgID != "" {
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID)
@@ -3602,6 +3761,10 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
return nil, err
}
msg.SeqId = seq
msg.ShardMonth = shardMonth
}
if isGroupMessage && msg.ShardMonth == "" {
msg.ShardMonth = msg.CreatedAt.UTC().Format("200601")
}
if clientMsgID != "" {
var duplicate *types.Message
@@ -3620,6 +3783,7 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
msg.DeletedFor = duplicate.DeletedFor
msg.SeqId = duplicate.SeqId
msg.Topic = duplicate.Topic
msg.ShardMonth = duplicate.ShardMonth
msg.From = duplicate.From
msg.ClientMsgID = duplicate.ClientMsgID
msg.Head = duplicate.Head
@@ -3627,7 +3791,6 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
msg.InitTimes()
msg.SetUid(Store.GetUid())
// Reserve topic's or user's SeqId before optional async persistence.
err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg)
@@ -3658,21 +3821,22 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
err = tenantAdp.TenantMessageSave(tenantID, msg)
}
if err != nil {
if isGroupMessage && err == types.ErrDuplicate && clientMsgID != "" {
duplicate, dupErr := tenantAdp.TenantGroupMessageGetByClientID(tenantID, msg.Topic, fromUid, clientMsgID)
if dupErr != nil {
return nil, dupErr
}
if duplicate != nil {
msg.SeqId = duplicate.SeqId
if groupMsgRuntime != nil {
_ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, fromUid, clientMsgID, duplicate.SeqId, duplicate.Uid().String())
if err == types.ErrDuplicate {
if isGroupMessage && clientMsgID != "" {
duplicate, dupErr := tenantAdp.TenantGroupMessageGetByClientID(tenantID, msg.Topic, fromUid, clientMsgID)
if dupErr != nil {
return nil, dupErr
}
if duplicate != nil {
msg.SeqId = duplicate.SeqId
msg.ShardMonth = duplicate.ShardMonth
if groupMsgRuntime != nil {
_ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, fromUid, clientMsgID, duplicate.SeqId, duplicate.Uid().String())
}
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
if !isGroupMessage && err == types.ErrDuplicate {
if clientMsgID != "" {
if !isGroupMessage && clientMsgID != "" {
duplicate, dupErr := tenantAdp.TenantMessageGetByClientID(tenantID, fromUid, clientMsgID)
if dupErr != nil {
return nil, dupErr
@@ -3682,12 +3846,14 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg); retryErr != nil {
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg, isGroupMessage); retryErr != nil {
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
return nil, retryErr
}
err = nil
}
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
} else if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
if err != nil {
@@ -3703,6 +3869,11 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
}
}
if isGroupMessage && groupMsgRuntime != nil {
if err := groupMsgRuntime.PutShardRoute(tenantID, msg.Topic, GroupMessageShardRoute{
Month: msg.ShardMonth, MinSeq: msg.SeqId, MaxSeq: msg.SeqId, Count: 1,
}); err != nil {
logs.Warn.Printf("topic[%s]: failed to update group message shard route: %v", msg.Topic, err)
}
if err := groupMsgRuntime.CacheHotMessage(tenantID, msg); err != nil {
logs.Warn.Printf("topic[%s]: failed to cache hot group message: %v", msg.Topic, err)
}
@@ -3765,6 +3936,7 @@ func copyPersistedMessage(dst, src *types.Message) {
dst.DeletedFor = src.DeletedFor
dst.SeqId = src.SeqId
dst.Topic = src.Topic
dst.ShardMonth = src.ShardMonth
dst.From = src.From
dst.ClientMsgID = src.ClientMsgID
dst.Head = src.Head
@@ -3783,19 +3955,52 @@ func NormalizeClientMsgID(head types.KVMap) (string, error) {
return normalizeClientMsgID(head)
}
func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message) error {
latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message, isGroupMessage bool) error {
var latest []types.Message
var err error
if isGroupMessage {
latest, err = tenantAdp.TenantGroupMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
} else {
latest, err = tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
}
if err != nil {
return err
}
if len(latest) == 0 || latest[0].SeqId < msg.SeqId {
return types.ErrDuplicate
}
prevSeq := msg.SeqId
msg.SeqId = latest[0].SeqId + 1
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return err
}
return tenantAdp.TenantMessageSave(tenantID, msg)
if isGroupMessage {
err = tenantAdp.TenantGroupMessageSave(tenantID, msg)
} else {
err = tenantAdp.TenantMessageSave(tenantID, msg)
}
if err != nil {
return err
}
// The re-allocated seq differs from the one handed out before the insert
// failed. Sync the Redis allocator and the idempotency record with the
// final seq, otherwise subsequent publishes keep colliding with the
// persisted row and resends of the same client mid return the abandoned
// seq forever.
BumpMessageSeqFloor(tenantID, msg.Topic, msg.SeqId)
if isGroupMessage {
// Drop the pending hot-cache entry the producer wrote for the
// abandoned seq so hot-range reads cannot surface a phantom message.
if err := InvalidateHotMessages(tenantID, msg.Topic, []types.Range{{Low: prevSeq, Hi: prevSeq + 1}}); err != nil {
logs.Warn.Printf("topic[%s]: failed to invalidate abandoned hot message seq=%d: %v", msg.Topic, prevSeq, err)
}
}
if msg.ClientMsgID != "" {
if fromUid := types.ParseUid(msg.From); !fromUid.IsZero() {
_ = MarkMessageUniqueDone(tenantID, msg.Topic, fromUid, msg.ClientMsgID, msg.SeqId, msg.Uid().String())
}
}
return nil
}
func normalizeClientMsgID(head types.KVMap) (string, error) {
@@ -3980,6 +4185,11 @@ func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID in
if cacheErr := groupMsgRuntime.InvalidateHotMessages(tenantID, topic, ranges); cacheErr != nil {
logs.Warn.Printf("topic[%s]: failed to invalidate deleted group message cache: %v", topic, cacheErr)
}
if delID == 0 {
if routeErr := groupMsgRuntime.DeleteShardRoutes(tenantID, topic); routeErr != nil {
logs.Warn.Printf("topic[%s]: failed to delete group message shard route: %v", topic, routeErr)
}
}
}
// TODO: move to adapter.
@@ -4034,6 +4244,20 @@ func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser type
return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit)
}
func (messagesMapper) Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error) {
if topic == "" || forUser.IsZero() || sinceVersion < 0 {
return nil, "", false, 0, types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", false, 0, err
}
return tenantAdp.TenantMessageMutations(tenantID, topic, forUser, sinceVersion, limit)
}
// GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list.
func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
+17
View File
@@ -57,3 +57,20 @@ type MessageAdminAction struct {
RequestID string
ProcessedAt time.Time
}
type ContentModerationLog struct {
RequestID string
Scene string
Topic string
SeqID *int64
ActorID Uid
Provider string
ProviderRequestID string
RiskLevel string
RiskLabels []string
Action string
ContentHash string
LatencyMS int64
ErrorCode string
CreatedAt time.Time
}
+2
View File
@@ -171,6 +171,8 @@ type GroupAnnouncement struct {
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
AuthorUserIDRaw int64 `db:"author_user_id"`
ReadCount int `db:"read_count"`
MemberCount int `db:"member_count"`
}
type GroupMute struct {
+45
View File
@@ -0,0 +1,45 @@
package types
type ModerationProviderConfig struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
RegionID string `json:"region_id"`
NicknameService string `json:"nickname_service"`
ChatService string `json:"chat_service"`
TimeoutMS int `json:"timeout_ms"`
FailureMode string `json:"failure_mode"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
}
type ModerationPolicy struct {
TenantID TenantID `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
PolicyVersion uint `json:"policy_version"`
Enabled bool `json:"enabled"`
AliyunEnabled bool `json:"aliyun_enabled"`
LocalWordsEnabled bool `json:"local_words_enabled"`
Scenes map[string]bool `json:"scenes"`
RiskActions map[string]string `json:"risk_actions"`
FailureMode string `json:"failure_mode"`
BlockMessage string `json:"block_message"`
}
type SensitiveWordRule struct {
ID int64 `json:"id"`
Word string `json:"word"`
MatchType string `json:"match_type"`
Scenes []string `json:"scenes"`
Action string `json:"action"`
Replacement string `json:"replacement"`
RiskLevel string `json:"risk_level"`
Priority int `json:"priority"`
}
type SensitiveWordSet struct {
TenantID TenantID `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
Version int64 `json:"version"`
Items []*SensitiveWordRule `json:"items"`
}
+17 -15
View File
@@ -29,24 +29,26 @@ type UserProfilePatch struct {
}
type UserPrivacySettings struct {
UserID Uid
AddMeMethods map[string]bool
ProfileVisibility string
LastSeenVisibility string
ReadReceipts bool
GroupInviteVerification bool
Version int
CreatedAt time.Time
UpdatedAt time.Time
UserID Uid
AddMeMethods map[string]bool
ProfileVisibility string
LastSeenVisibility string
ReadReceipts bool
FriendRequestVerification bool
GroupInviteVerification bool
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
type UserPrivacyPatch struct {
AddMeMethods map[string]bool
ProfileVisibility *string
LastSeenVisibility *string
ReadReceipts *bool
GroupInviteVerification *bool
Version int
AddMeMethods map[string]bool
ProfileVisibility *string
LastSeenVisibility *string
ReadReceipts *bool
FriendRequestVerification *bool
GroupInviteVerification *bool
Version int
}
type AppRelease struct {
+18
View File
@@ -138,6 +138,10 @@ func (uid *Uid) MarshalJSON() ([]byte, error) {
// UnmarshalJSON reads Uid from a double quoted string.
func (uid *Uid) UnmarshalJSON(b []byte) error {
size := len(b)
if size == 2 && b[0] == '"' && b[1] == '"' {
*uid = ZeroUid
return nil
}
if size != (uidBase64Unpadded + 2) {
return errors.New("Uid.UnmarshalJSON: invalid length")
} else if b[0] != '"' || b[size-1] != '"' {
@@ -1151,6 +1155,9 @@ type Topic struct {
// Count of topic subscribers.
SubCnt int
// Conversation-level version for in-place message mutations such as recall and edit.
MutationVersion int64 `db:"mutation_version" json:"MutationVersion,omitempty" bson:"mutation_version,omitempty"`
Public any
Trusted any
@@ -1241,6 +1248,7 @@ type Message struct {
DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"`
SeqId int64
Topic string
ShardMonth string `db:"shard_month" json:"-" bson:"shard_month,omitempty"`
// Sender's user ID as string (without 'usr' prefix), could be empty.
From string
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
@@ -1264,6 +1272,16 @@ type MessageRevision struct {
Reason string
}
// MessageMutation describes an in-place message change used for offline compensation.
type MessageMutation struct {
SeqId int64
Version int
Event string
Operator string
MutatedAt time.Time
MutationVersion int64
}
// MessageSaveResult describes the outcome of a message persistence attempt.
type MessageSaveResult struct {
SeqId int64
+23
View File
@@ -0,0 +1,23 @@
package types
import (
"encoding/json"
"testing"
)
func TestZeroUidJSONRoundTrip(t *testing.T) {
type wrapper struct {
UID Uid
}
raw, err := json.Marshal(&wrapper{UID: ZeroUid})
if err != nil {
t.Fatal(err)
}
var decoded wrapper
if err = json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.UID != ZeroUid {
t.Fatalf("decoded uid=%v want zero", decoded.UID)
}
}
+297 -13
View File
@@ -10,6 +10,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"sort"
"strconv"
@@ -19,6 +20,7 @@ import (
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -352,7 +354,30 @@ func (t *Topic) registerSession(msg *ClientComMessage) {
// but there is a gap between topic creation/un-pausing and processing the
// first subscription request, before the topic is linked to session: a client
// may send several subscription requests in that gap.
msg.sess.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp))
//
// A p2p topic may be addressed by an alias (the peer's user id, e.g. "usrXXX")
// while the session is already attached to the same topic under its canonical
// name ("p2pXXX"). Replying 304 here leaves the client-side topic object created
// for the alias name unattached: the SDK treats any ctrl code >= 300 as failure
// and skips the alias->canonical rename, so publishes on it fail with
// "Cannot publish on inactive topic". Reply 200 with the canonical name and the
// effective access mode so the client can complete the rename instead.
asUid := types.ParseUserId(msg.AsUser)
if pud, ok := t.perUser[asUid]; ok && t.cat == types.TopicCatP2P && msg.Original != t.name {
msg.sess.queueOut(NoErrParams(msg.Id, t.name, types.TimeNow(), map[string]any{
"acs": &MsgAccessMode{
Want: pud.modeWant.String(),
Given: pud.modeGiven.String(),
Mode: (pud.modeGiven & pud.modeWant).String(),
},
}))
// The client-side topic object created for the alias name is fresh:
// serve the {get} query it sent along with the subscription, otherwise
// the reopened conversation would come up without desc/subs/history.
t.processSubGetQuery(msg, asUid, false, auth.Level(msg.AuthLvl))
} else {
msg.sess.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp))
}
} else {
// The topic is alive, so stop the kill timer, if it's ticking. We don't want the topic to die
// while processing the call.
@@ -656,6 +681,18 @@ func (t *Topic) handleServerMsg(msg *ServerComMessage) {
if msg.Pres != nil {
t.handlePresence(msg)
} else if msg.Info != nil || msg.Data != nil {
if msg.Data != nil && msg.Data.SeqId > t.lastID {
t.lastID = msg.Data.SeqId
if !msg.Data.Timestamp.IsZero() {
t.touched = msg.Data.Timestamp
}
}
if msg.Info != nil && msg.Info.What == "group" {
// Group management event published by the HTTP or Kafka admin
// interface: update in-memory topic state (cached permissions,
// owner, lifecycle) before broadcasting the event to clients.
t.handleGroupInfoEvent(msg.Info)
}
t.broadcastToSessions(msg)
} else {
// TODO(gene): maybe remove this panic.
@@ -663,6 +700,199 @@ func (t *Topic) handleServerMsg(msg *ServerComMessage) {
}
}
// flexUID accepts a JSON string or number representation of a user id: the
// HTTP interface publishes base32 user id strings, while Kafka admin commands
// publish numeric database ids.
type flexUID struct {
num int64
str string
isNum bool
}
func (f *flexUID) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" {
return nil
}
if b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
f.str = s
return nil
}
if err := json.Unmarshal(b, &f.num); err != nil {
return err
}
f.isNum = true
return nil
}
func (f flexUID) uid() types.Uid {
if f.isNum {
return types.Uid(f.num)
}
if f.str == "" {
return types.ZeroUid
}
return types.ParseUserId(f.str)
}
// groupEventPayload is a union of the payloads published with group.* realtime
// events by the HTTP and Kafka admin interfaces.
type groupEventPayload struct {
Owner flexUID `json:"owner"`
PreviousOwner flexUID `json:"previous_owner"`
UserID flexUID `json:"user_id"`
Items []struct {
UserID flexUID `json:"user_id"`
} `json:"items"`
Results []struct {
UserID flexUID `json:"user_id"`
OK bool `json:"ok"`
} `json:"results"`
}
// handleGroupInfoEvent applies server-published group management events to the
// in-memory topic state: refreshes the cached per-user permissions or shuts the
// topic down when the group is dissolved. The event is still broadcast to the
// connected clients afterwards by the caller.
func (t *Topic) handleGroupInfoEvent(info *MsgServerInfo) {
var payload groupEventPayload
if len(info.Payload) > 0 {
if err := json.Unmarshal(info.Payload, &payload); err != nil {
logs.Warn.Printf("topic[%s]: failed to decode group event payload event=%s: %v", t.name, info.Event, err)
}
}
actor := types.ParseUserId(info.From)
if actor.IsZero() {
actor = payload.UserID.uid()
}
switch info.Event {
case "group.owner.transferred":
newOwner := payload.Owner.uid()
prevOwner := payload.PreviousOwner.uid()
t.refreshPerUserFromDB(actor, newOwner, prevOwner)
if !newOwner.IsZero() && t.owner == prevOwner {
t.owner = newOwner
}
case "group.role.updated", "group.member.updated", "group.member.left", "group.mute.updated":
t.refreshPerUserFromDB(actor, groupEventAffectedUids(&payload)...)
case "group.dissolved":
// The group is dissolved: stop accepting requests, unregister from the
// hub and terminate. handleTopicTermination will detach all sessions
// and notify the subscribers.
t.markPaused(true)
if globals.hub != nil {
globals.hub.topicDel(types.TopicKey{TenantID: t.tenantID, Topic: t.name})
}
select {
case t.exit <- &shutDown{reason: StopDeleted}:
default:
}
}
}
// groupEventAffectedUids extracts the ids of users affected by a group event.
func groupEventAffectedUids(p *groupEventPayload) []types.Uid {
uids := make([]types.Uid, 0, 1+len(p.Items)+len(p.Results))
if uid := p.UserID.uid(); !uid.IsZero() {
uids = append(uids, uid)
}
for _, item := range p.Items {
if uid := item.UserID.uid(); !uid.IsZero() {
uids = append(uids, uid)
}
}
for _, item := range p.Results {
if uid := item.UserID.uid(); !uid.IsZero() {
uids = append(uids, uid)
}
}
return uids
}
// refreshPerUserFromDB reloads the cached per-user permissions for the given
// users from the database, e.g. after an out-of-band change (HTTP/Kafka) such
// as an owner transfer, role update or mute. Users whose subscription is
// missing or deleted are dropped from the cache.
func (t *Topic) refreshPerUserFromDB(actor types.Uid, uids ...types.Uid) {
if t.isProxy {
// Proxy topics carry no per-user state; the master node handles it.
return
}
changed := false
for _, uid := range uids {
if uid.IsZero() {
continue
}
if actor.IsZero() {
actor = uid
}
sub, err := store.Subs.Get(t.tenantID, t.name, uid, true)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to reload subscription for user %s: %v", t.name, uid.UserId(), err)
continue
}
pud, found := t.perUser[uid]
if sub == nil || sub.DeletedAt != nil {
// Subscription is gone: notify and detach the user's sessions.
if !found {
continue
}
oldWant, oldGiven := pud.modeWant, pud.modeGiven
t.notifySubChange(uid, actor, false,
oldWant, oldGiven, types.ModeUnset, types.ModeUnset, "")
t.evictUser(uid, true, "")
changed = true
continue
}
if !found {
// Member added while the topic was live.
t.perUser[uid] = perUserData{
private: sub.Private,
modeWant: sub.ModeWant,
modeGiven: sub.ModeGiven,
}
changed = true
t.notifySubChange(uid, actor, false,
types.ModeNone, types.ModeNone, sub.ModeWant, sub.ModeGiven, "")
continue
}
oldWant, oldGiven := pud.modeWant, pud.modeGiven
if oldWant == sub.ModeWant && oldGiven == sub.ModeGiven {
continue
}
pud.modeWant = sub.ModeWant
pud.modeGiven = sub.ModeGiven
t.perUser[uid] = pud
changed = true
// Mirror the unread count adjustments made on regular mode changes.
oldReader := (oldWant & oldGiven).IsReader()
newReader := (sub.ModeWant & sub.ModeGiven).IsReader()
if oldReader && !newReader {
usersUpdateUnread(t.tenantID, uid, pud.readID-t.lastID, true)
} else if !oldReader && newReader {
usersUpdateUnread(t.tenantID, uid, t.lastID-pud.readID, true)
}
t.notifySubChange(uid, actor, false,
oldWant, oldGiven, sub.ModeWant, sub.ModeGiven, "")
}
if changed {
t.computePerUserAcsUnion()
}
}
// Session subscribed to a topic, created == true if topic was just created and {pres} needs to be announced
func (t *Topic) handleSubscription(msg *ClientComMessage) error {
asUid := types.ParseUserId(msg.AsUser)
@@ -679,60 +909,71 @@ func (t *Topic) handleSubscription(msg *ClientComMessage) error {
}
msgsub := msg.Sub
getWhat := 0
if msgsub.Get != nil {
getWhat = parseMsgClientMeta(msgsub.Get.What)
// Deliver the data requested by the {get} query attached to the subscription.
t.processSubGetQuery(msg, asUid, asChan, authLevel)
}
return nil
}
// processSubGetQuery responds to the {get} query attached to a subscription
// request (desc/sub/tags/cred/aux/data/del) by sending the answers as separate
// {meta}/{data} packets.
func (t *Topic) processSubGetQuery(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) {
msgsub := msg.Sub
if msgsub == nil || msgsub.Get == nil {
return
}
getWhat := parseMsgClientMeta(msgsub.Get.What)
if getWhat&constMsgMetaDesc != 0 {
// Send get.desc as a {meta} packet.
if err := t.replyGetDesc(msg.sess, asUid, asChan, msgsub.Get.Desc, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Desc failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Desc failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
if getWhat&constMsgMetaSub != 0 {
// Send get.sub response as a separate {meta} packet
if err := t.replyGetSub(msg.sess, asUid, authLevel, asChan, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Sub failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Sub failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
if getWhat&constMsgMetaTags != 0 {
// Send get.tags response as a separate {meta} packet
if err := t.replyGetTags(msg.sess, asUid, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Tags failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Tags failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
if getWhat&constMsgMetaCred != 0 {
// Send get.tags response as a separate {meta} packet
// Send get.cred response as a separate {meta} packet
if err := t.replyGetCreds(msg.sess, asUid, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Cred failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Cred failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
if getWhat&constMsgMetaAux != 0 {
// Send get.aux response as a separate {meta} packet
if err := t.replyGetAux(msg.sess, asUid, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Aux failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Aux failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
if getWhat&constMsgMetaData != 0 {
// Send get.data response as {data} packets
if err := t.replyGetData(msg.sess, asUid, asChan, msgsub.Get.Data, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Data failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Data failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
if getWhat&constMsgMetaDel != 0 {
// Send get.del response as a separate {meta} packet
if err := t.replyGetDel(msg.sess, asUid, msgsub.Get.Del, msg); err != nil {
logs.Warn.Printf("topic[%s] handleSubscription Get.Del failed: %v sid=%s", t.name, err, msg.sess.sid)
logs.Warn.Printf("topic[%s] Get.Del failed: %v sid=%s", t.name, err, msg.sess.sid)
}
}
return nil
}
// handleLeaveRequest processes a session leave request.
@@ -1254,6 +1495,22 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.Pub.Head = head
msg.Pub.Content = content
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && !isCall {
scene := moderation.ScenePrivateMessage
if t.cat == types.TopicCatGrp {
scene = moderation.SceneGroupMessage
}
if text := moderation.ExtractText(msg.Pub.Content); text != "" {
result, reviewErr := moderation.Review(context.Background(), moderation.Request{
TenantID: t.tenantID, TenantCode: msg.sess.tenantCode, Scene: scene,
ActorID: asUid, Topic: t.name, Text: text, TraceID: msg.Id,
})
if reviewErr != nil {
msg.sess.queueOut(ErrContentModerationReply(msg, types.TimeNow(), reviewErr, result))
return
}
}
}
// Save to DB at master topic.
attachments := messageAttachmentURLs(msg)
@@ -1612,6 +1869,7 @@ func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {
}
t.broadcastToSessions(info)
publishRealtimeTopicMessage(t.name, info)
}
// handlePresence fans out {pres} messages to recipients in topic.
@@ -2639,6 +2897,32 @@ func (t *Topic) replySetDesc(sess *Session, asUid types.Uid, asChan bool,
sess.queueOut(ErrPermissionDeniedReply(msg, now))
return errors.New("attempt to change Trusted by non-root")
}
if sess != nil && authLevel != auth.LevelRoot {
reviewMeta := func(scene string, value any) error {
text := moderation.ExtractText(value)
if text == "" {
return nil
}
result, reviewErr := moderation.Review(context.Background(), moderation.Request{TenantID: t.tenantID, TenantCode: sess.tenantCode, Scene: scene, ActorID: asUid, Topic: t.name, Text: text, TraceID: msg.Id})
if reviewErr != nil {
sess.queueOut(ErrContentModerationReply(msg, now, reviewErr, result))
return reviewErr
}
return nil
}
if t.cat == types.TopicCatMe {
if err = reviewMeta(moderation.SceneUserNickname, set.Desc.Public); err != nil {
return err
}
} else if t.cat == types.TopicCatGrp {
if err = reviewMeta(moderation.SceneGroupName, set.Desc.Public); err != nil {
return err
}
if err = reviewMeta(moderation.SceneGroupNickname, set.Desc.Private); err != nil {
return err
}
}
}
switch t.cat {
case types.TopicCatMe:
+52
View File
@@ -1061,15 +1061,20 @@ func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
defer ctrl.Finish()
oldKafkaBus := kafkaBus
oldStore := store.Store
oldMessages := store.Messages
oldPublisher := publishKafkaMessagePersist
defer func() {
kafkaBus = oldKafkaBus
store.Store = oldStore
store.Messages = oldMessages
publishKafkaMessagePersist = oldPublisher
}()
kafkaBus = &kafkaRuntime{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetUid().Return(types.Uid(101))
store.Store = mockStore
store.Messages = mock_store.NewMockMessagesPersistenceInterface(ctrl)
var publishedTopic string
var publishedKey string
@@ -1115,6 +1120,14 @@ func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
if publishedEnvelope.IdempotencyKey != "client-1" {
t.Fatalf("unexpected idempotency key: %q", publishedEnvelope.IdempotencyKey)
}
var payload kafkaMessagePersistPayload
if err := json.Unmarshal(publishedEnvelope.Payload, &payload); err != nil {
t.Fatalf("decode kafka message payload: %v", err)
}
wantMonth := msg.CreatedAt.UTC().Format("200601")
if payload.ShardMonth != wantMonth {
t.Fatalf("group message shard month was not frozen: payload=%q want=%q", payload.ShardMonth, wantMonth)
}
}
func TestShouldTryHotMessageFallback(t *testing.T) {
@@ -1858,6 +1871,45 @@ func TestHandleBroadcastPresMe(t *testing.T) {
}
}
func TestHandleServerDataAdvancesLastID(t *testing.T) {
topicName := "p2pZAAAAAAAAABAAAAAAAAAAA"
helper := TopicTestHelper{}
helper.setUp(t, 2, types.TopicCatP2P, topicName, true)
defer helper.tearDown()
timestamp := time.Now().UTC().Round(time.Millisecond)
helper.topic.handleServerMsg(&ServerComMessage{
TenantID: types.TenantID(1),
RcptTo: topicName,
PreserveTopicName: true,
Data: &MsgServerData{
Topic: topicName,
From: types.ZeroUid.UserId(),
Timestamp: timestamp,
SeqId: 1,
Head: map[string]any{"x-im-type": "system_notice"},
Content: map[string]any{"text": "你们已成为好友,现在可以开始聊天了。"},
},
})
helper.finish()
if helper.topic.lastID != 1 {
t.Fatalf("Topic.lastID: expected server data to advance to 1, got %d", helper.topic.lastID)
}
if !helper.topic.touched.Equal(timestamp) {
t.Fatalf("Topic.touched: expected %s, got %s", timestamp, helper.topic.touched)
}
for i, result := range helper.results {
if len(result.messages) != 1 {
t.Fatalf("session %d expected one server data message, got %d", i, len(result.messages))
}
msg := result.messages[0].(*ServerComMessage)
if msg.Data == nil || msg.Data.SeqId != 1 {
t.Fatalf("session %d received unexpected message: %#v", i, msg)
}
}
}
func TestHandleBroadcastPresInactiveTopic(t *testing.T) {
topicName := "usrMe"
numUsers := 1
+31 -13
View File
@@ -118,11 +118,12 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Pre-check credentials for validity. We don't know user's access level
// consequently cannot check presence of required credentials. Must do that later.
creds := normalizeCredentials(msg.Acc.Cred, true)
rawCreds := normalizeCredentials(msg.Acc.Cred, true)
creds := make([]MsgCredClient, 0, len(rawCreds))
var registrationPhone string
var registrationTelCredential bool
for i := range creds {
cr := &creds[i]
for i := range rawCreds {
cr := &rawCreds[i]
vld := store.Store.GetValidator(cr.Method)
if vld == nil || !vld.IsInitialized() {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
@@ -138,6 +139,16 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
if cr.Method == "tel" {
phone := strings.TrimSpace(strings.TrimPrefix(normalized, "tel:"))
if credentialInviteCode := strings.TrimSpace(cr.Response); isValidIMInviteCode(credentialInviteCode) {
if inviteCode != "" && inviteCode != credentialInviteCode {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
return
}
inviteCode = credentialInviteCode
registrationPhone = phone
continue
}
challengeID, _ := cr.Params["challenge_id"].(string)
if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" ||
(registrationPhone != "" && registrationPhone != phone) {
@@ -148,6 +159,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
registrationPhone = phone
registrationTelCredential = true
}
creds = append(creds, *cr)
}
if !phoneRegistration && registrationTelCredential {
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
@@ -155,19 +167,19 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
return
}
if phoneRegistration && registrationTelCredential && registrationPhone != phone {
if phoneRegistration && registrationPhone != "" && registrationPhone != phone {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
return
}
smsEnabled := registrationSMSEnabled(time.Now().UTC())
if phoneRegistration && smsEnabled && !registrationTelCredential {
if phoneRegistration && smsEnabled && !registrationTelCredential && inviteCode == "" {
logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
return
}
inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential)
inviteRequired := phoneRegistration && !smsEnabled && !registrationTelCredential
if inviteRequired && inviteCode == "" {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"}))
@@ -240,10 +252,14 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
// When creating an account, the user must provide all required credentials.
// If any are missing, reject the request.
if len(creds) < len(globals.authValidators[rec.AuthLevel]) {
requiredValidators := globals.authValidators[rec.AuthLevel]
if inviteCode != "" {
requiredValidators = slices.DeleteFunc(slices.Clone(requiredValidators), func(method string) bool { return method == "tel" })
}
if len(creds) < len(requiredValidators) {
logs.Warn.Println("create user: missing credentials; have:", creds, "want:",
globals.authValidators[rec.AuthLevel], s.sid)
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], credentialMethods(creds))
requiredValidators, s.sid)
_, missing, _ := stringSliceDelta(requiredValidators, credentialMethods(creds))
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"creds": missing}))
@@ -310,7 +326,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
var reply *ServerComMessage
if msg.Acc.Login {
// Process user's login request.
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)
_, missing, _ := stringSliceDelta(requiredValidators, validated)
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Acc.Scheme, account)
} else {
// Not using the new account for logging in.
@@ -824,7 +840,7 @@ func changeUserState(s *Session, uid types.Uid, user *types.User, msg *ClientCom
if state != types.StateOK {
// Terminate all sessions.
globals.sessionStore.EvictUser(uid, "")
globals.sessionStore.EvictUser(uid, "", "user_state_changed")
publishRealtimeEvictUser(s.tenantID, uid, "user_state_changed", "")
}
@@ -900,7 +916,7 @@ func replyDelUser(s *Session, msg *ClientComMessage) {
}
// 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, "user_deleted")
publishRealtimeEvictUser(s.tenantID, uid, "user_deleted", s.sid)
// Remove user from cache and announce to cluster that the user is deleted.
usersRemoveUser(msg.TenantID, uid)
@@ -944,7 +960,9 @@ func replyDelUser(s *Session, msg *ClientComMessage) {
if s.uid == uid && s.multi == nil {
// Evict the current session if it belongs to the deleted user.
// No need to send it to multiplexing session: remote node will be notified separately.
_, data := s.serialize(NoErrEvicted("", "", msg.Timestamp))
evicted := NoErrEvicted("", "", msg.Timestamp)
evicted.Ctrl.Params = map[string]any{"reason": "user_deleted"}
_, data := s.serialize(evicted)
s.stopSession(data)
}
}
+20
View File
@@ -9,6 +9,26 @@ import (
"github.com/tinode/chat/server/store/types"
)
type registrationTestValidator struct{}
func (registrationTestValidator) Init(string) error { return nil }
func (registrationTestValidator) IsInitialized() bool { return true }
func (registrationTestValidator) PreCheck(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
}
func (registrationTestValidator) Request(types.TenantID, types.Uid, string, string, string, []byte) (bool, error) {
return true, nil
}
func (registrationTestValidator) ResetSecret(types.TenantID, string, string, string, []byte, map[string]any) error {
return nil
}
func (registrationTestValidator) Check(types.TenantID, types.Uid, string) (string, error) {
return "", nil
}
func (registrationTestValidator) Remove(types.TenantID, types.Uid, string) error { return nil }
func (registrationTestValidator) Delete(types.TenantID, types.Uid) error { return nil }
func (registrationTestValidator) TempAuthScheme() (string, error) { return "code", nil }
func TestResolveRegistrationAccount(t *testing.T) {
tests := []struct {
name string
+21
View File
@@ -741,6 +741,27 @@ func platformFromUA(ua string) string {
return ""
}
// sameEvictPlatform reports whether two session platforms belong to the same
// eviction group for same-platform login eviction (顶号互踢): identical
// identifiers match, and ios/android/mobile are all treated as one mobile group
// (an iOS login evicts Android sessions and vice versa). Web only matches web.
func sameEvictPlatform(a, b string) bool {
if a == "" || b == "" {
return false
}
if strings.EqualFold(a, b) {
return true
}
switch strings.ToLower(a) {
case "ios", "android", "mobile":
switch strings.ToLower(b) {
case "ios", "android", "mobile":
return true
}
}
return false
}
func parseTLSConfig(tlsEnabled bool, jsconfig json.RawMessage) (*tls.Config, error) {
type tlsAutocertConfig struct {
// Domains to support by autocert