Compare commits
34
Commits
15abc1b6e8
...
c8d9146604
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d9146604 | ||
|
|
ed976c76ff | ||
|
|
50f8581667 | ||
|
|
760de35e10 | ||
|
|
8279ae577f | ||
|
|
734e042af6 | ||
|
|
816be46e03 | ||
|
|
7df3313c78 | ||
|
|
0f8e3b82b7 | ||
|
|
0ae4af0c31 | ||
|
|
6729f40665 | ||
|
|
7461216c03 | ||
|
|
370e9878ed | ||
|
|
fa95dd72af | ||
|
|
1038d25abd | ||
|
|
a01cdae971 | ||
|
|
c0ab94723c | ||
|
|
846615db69 | ||
|
|
4280b535c2 | ||
|
|
68b196215f | ||
|
|
1de69fcc5b | ||
|
|
9626e8ee7c | ||
|
|
139681fadb | ||
|
|
b7c2536527 | ||
|
|
dbb0a06db0 | ||
|
|
563ae9ee5e | ||
|
|
e29b5d3589 | ||
|
|
152e2402fa | ||
|
|
bc27a5c574 | ||
|
|
55afd976c9 | ||
|
|
5dd0a42002 | ||
|
|
4f6a996922 | ||
|
|
9ec5da5b02 | ||
|
|
ef8091d805 |
@@ -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
@@ -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¤t_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
@@ -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
@@ -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"}` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 拉取
|
||||
```
|
||||
|
||||
客户端协议无需增加月份字段,月表路由完全由服务端处理。
|
||||
@@ -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
@@ -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` | 消息结构定义 |
|
||||
|
||||
@@ -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));
|
||||
@@ -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',
|
||||
|
||||
@@ -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¤t_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”闭环。
|
||||
+269
-9
@@ -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"}` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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(¤t)
|
||||
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(¬eRefs); 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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -167,5 +167,5 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ensureGroupMessageShardInfrastructure(exec)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¤tVersion, "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, ¤tVersion, "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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+567
-14
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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, "引流"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ¤t, 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, ":")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user