Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev
This commit is contained in:
+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
|
||||
|
||||
@@ -1420,6 +1420,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
| `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` | 单条撤回;`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":""}` |
|
||||
@@ -1429,6 +1430,225 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
消息撤回和编辑成功后,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. 通讯录、好友关系与黑名单
|
||||
|
||||
### 5.1 通讯录.png
|
||||
|
||||
@@ -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 拉取
|
||||
```
|
||||
|
||||
客户端协议无需增加月份字段,月表路由完全由服务端处理。
|
||||
@@ -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,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”闭环。
|
||||
@@ -1379,6 +1379,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
| `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` | 单条撤回;`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":""}` |
|
||||
@@ -1388,6 +1389,225 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
消息撤回和编辑成功后,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. 通讯录、好友关系与黑名单
|
||||
|
||||
### 5.1 通讯录.png
|
||||
|
||||
@@ -1317,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,
|
||||
@@ -1617,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);
|
||||
|
||||
|
||||
@@ -332,6 +332,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 +352,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 +389,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)
|
||||
|
||||
+38
-43
@@ -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
|
||||
}
|
||||
|
||||
@@ -1819,26 +1819,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 +1852,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 +2741,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 +2895,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 +2937,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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+485
-120
@@ -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)
|
||||
@@ -6902,19 +7250,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 +7760,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()
|
||||
|
||||
@@ -930,6 +930,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
|
||||
|
||||
@@ -98,7 +98,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 +108,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 +127,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 +155,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 +164,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"
|
||||
|
||||
+171
-12
@@ -4074,6 +4074,8 @@ func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
|
||||
serveIMTopicDraft(wrt, req)
|
||||
case strings.HasSuffix(req.URL.Path, "/calls"):
|
||||
serveIMTopicCalls(wrt, req)
|
||||
case strings.HasSuffix(req.URL.Path, "/message-mutations"):
|
||||
serveIMMessageMutations(wrt, req)
|
||||
case strings.HasSuffix(req.URL.Path, "/messages/search"):
|
||||
serveIMMessageSearch(wrt, req)
|
||||
case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"):
|
||||
@@ -4198,6 +4200,40 @@ func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
||||
}
|
||||
|
||||
func serveIMMessageMutations(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessageMutationsPath(req.URL.Path))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sinceVersion, err := parseIMMutationSince(req.URL.Query().Get("since_version"), req.URL.Query().Get("since"))
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "since_version"})
|
||||
return
|
||||
}
|
||||
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
||||
mutations, nextCursor, hasMore, mutationVersion, err := store.Messages.Mutations(principal.TenantID, topic, principal.Uid, sinceVersion, limit)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(mutations))
|
||||
for _, mutation := range mutations {
|
||||
items = append(items, messageMutationEventPayload(mutation))
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"topic": topic,
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"has_more": hasMore,
|
||||
"mutation_version": mutationVersion,
|
||||
"expired": false,
|
||||
})
|
||||
}
|
||||
|
||||
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -5583,6 +5619,11 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
|
||||
}
|
||||
if joined {
|
||||
item["status"] = types.GroupInvitationStatusJoined
|
||||
if imReliableHTTPEventsEnabled(req) {
|
||||
publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, uid,
|
||||
"有成员通过邀请加入了群聊。",
|
||||
map[string]any{"source": "direct_invitation"})
|
||||
}
|
||||
} else {
|
||||
item["status"] = "already_member"
|
||||
}
|
||||
@@ -5827,6 +5868,11 @@ func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic
|
||||
}
|
||||
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
|
||||
map[string]any{"user_id": principal.Uid.UserId(), "joined": joined, "source": "open_group"})
|
||||
if joined && imReliableHTTPEventsEnabled(req) {
|
||||
publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, principal.Uid,
|
||||
"有成员加入了群聊。",
|
||||
map[string]any{"source": "open_group"})
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "joined", "joined": joined, "source": "open_group"})
|
||||
return
|
||||
}
|
||||
@@ -6347,19 +6393,34 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return
|
||||
}
|
||||
isAdmin := (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
||||
switch {
|
||||
case tail == "mutes" && req.Method == http.MethodGet:
|
||||
items, nextCursor, err := store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
var items []types.GroupMute
|
||||
nextCursor := ""
|
||||
if isAdmin {
|
||||
var err error
|
||||
items, nextCursor, err = store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
item, err := store.GroupBusiness.GetMute(principal.TenantID, topic, principal.Uid)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
if item != nil {
|
||||
items = append(items, *item)
|
||||
}
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
||||
case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost:
|
||||
if !isAdmin {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return
|
||||
}
|
||||
var body imGroupMuteBatchRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
@@ -6400,6 +6461,10 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
|
||||
map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
||||
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
|
||||
if !isAdmin {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return
|
||||
}
|
||||
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
|
||||
if uid.IsZero() {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
||||
@@ -8712,6 +8777,17 @@ func messageSearchPayload(msg types.Message, query string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func messageMutationEventPayload(mutation types.MessageMutation) map[string]any {
|
||||
return map[string]any{
|
||||
"seq": mutation.SeqId,
|
||||
"version": mutation.Version,
|
||||
"event": mutation.Event,
|
||||
"operator": mutation.Operator,
|
||||
"mutated_at": mutation.MutatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"mutation_version": mutation.MutationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func messageContextPayload(msg types.Message) map[string]any {
|
||||
return map[string]any{
|
||||
"seq": msg.SeqId,
|
||||
@@ -11583,7 +11659,9 @@ func broadcastIMConversationSystemMessage(tenantID types.TenantID, msg *types.Me
|
||||
logs.Warn.Printf("im system message: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
|
||||
}
|
||||
deliverIMConversationSystemMessageToP2PSessions(tenantID, msg, out)
|
||||
publishRealtimeTopicMessage(msg.Topic, out)
|
||||
if !publishRealtimeP2PConversationSystemMessage(tenantID, msg.Topic, out) {
|
||||
publishRealtimeTopicMessage(msg.Topic, out)
|
||||
}
|
||||
broadcastIMConversationSystemPresence(tenantID, msg, timestamp)
|
||||
}
|
||||
|
||||
@@ -11607,6 +11685,28 @@ func publishIMGroupInvitationNotification(tenantID types.TenantID, inv *types.Gr
|
||||
}
|
||||
}
|
||||
|
||||
func publishIMGroupMemberJoinedSystemNotice(tenantID types.TenantID, topic string, inviter, target types.Uid, text string, extra map[string]any) {
|
||||
if target.IsZero() {
|
||||
return
|
||||
}
|
||||
actors := make([]imSystemMessageActor, 0, 2)
|
||||
if !inviter.IsZero() && inviter != target {
|
||||
actors = append(actors, imSystemMessageActor{
|
||||
UserID: inviter.UserId(),
|
||||
Click: map[string]any{"type": "user_profile", "target_id": inviter.UserId()},
|
||||
})
|
||||
}
|
||||
actors = append(actors, imSystemMessageActor{
|
||||
UserID: target.UserId(),
|
||||
Click: map[string]any{"type": "user_profile", "target_id": target.UserId()},
|
||||
})
|
||||
publishIMConversationSystemNotice(tenantID, topic,
|
||||
"system_action", "group_member_joined", text,
|
||||
actors,
|
||||
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}},
|
||||
extra)
|
||||
}
|
||||
|
||||
func deliverIMConversationSystemMessageToP2PSessions(tenantID types.TenantID, msg *types.Message, out *ServerComMessage) {
|
||||
if globals.sessionStore == nil || msg == nil || out == nil || out.Data == nil {
|
||||
return
|
||||
@@ -11629,9 +11729,10 @@ func deliverIMConversationSystemMessageToP2PSessions(tenantID types.TenantID, ms
|
||||
if session.getSub(msg.Topic) != nil {
|
||||
return true
|
||||
}
|
||||
msgCopy := out.copy()
|
||||
msgCopy.RcptTo = session.uid.UserId()
|
||||
msgCopy.Data.Topic = msg.Topic
|
||||
msgCopy, ok := p2pConversationSystemMessageForUser(out, msg.Topic, session.uid)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if !session.queueOut(msgCopy) {
|
||||
logs.Warn.Printf("im system message: session queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, session.uid.UserId())
|
||||
}
|
||||
@@ -11639,6 +11740,43 @@ func deliverIMConversationSystemMessageToP2PSessions(tenantID types.TenantID, ms
|
||||
})
|
||||
}
|
||||
|
||||
func publishRealtimeP2PConversationSystemMessage(tenantID types.TenantID, topic string, out *ServerComMessage) bool {
|
||||
uid1, uid2, err := types.ParseP2P(topic)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, uid := range []types.Uid{uid1, uid2} {
|
||||
msgCopy, ok := p2pConversationSystemMessageForUser(out, topic, uid)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
publishRealtimeUserMessage(uid, msgCopy)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func p2pConversationSystemMessageForUser(out *ServerComMessage, topic string, uid types.Uid) (*ServerComMessage, bool) {
|
||||
if out == nil || uid.IsZero() {
|
||||
return nil, false
|
||||
}
|
||||
viewTopic, err := types.P2PNameForUser(uid, topic)
|
||||
if err != nil || viewTopic == "" {
|
||||
return nil, false
|
||||
}
|
||||
msgCopy := out.copy()
|
||||
msgCopy.RcptTo = uid.UserId()
|
||||
if msgCopy.Data != nil {
|
||||
msgCopy.Data.Topic = viewTopic
|
||||
}
|
||||
if msgCopy.Info != nil {
|
||||
msgCopy.Info.Topic = viewTopic
|
||||
}
|
||||
if msgCopy.Pres != nil {
|
||||
msgCopy.Pres.Topic = viewTopic
|
||||
}
|
||||
return msgCopy, true
|
||||
}
|
||||
|
||||
func broadcastIMConversationSystemPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
|
||||
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
|
||||
return
|
||||
@@ -11724,6 +11862,12 @@ func topicFromMessagesSearchPath(path string) string {
|
||||
return topicFromPathWithSuffix(path, prefix, suffix)
|
||||
}
|
||||
|
||||
func topicFromMessageMutationsPath(path string) string {
|
||||
const prefix = "/v1/im/topics/"
|
||||
const suffix = "/message-mutations"
|
||||
return topicFromPathWithSuffix(path, prefix, suffix)
|
||||
}
|
||||
|
||||
func topicFromMediaPath(path string) string {
|
||||
const prefix = "/v1/im/topics/"
|
||||
const suffix = "/media"
|
||||
@@ -11751,6 +11895,21 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string {
|
||||
return strings.Trim(rest, "/")
|
||||
}
|
||||
|
||||
func parseIMMutationSince(primary, fallback string) (int64, error) {
|
||||
value := strings.TrimSpace(primary)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(fallback)
|
||||
}
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func messageContextPath(path string) (string, int64, bool) {
|
||||
const prefix = "/v1/im/topics/"
|
||||
idx := strings.Index(path, prefix)
|
||||
|
||||
+251
-1
@@ -548,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)
|
||||
@@ -5370,6 +5379,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()
|
||||
@@ -5625,6 +5702,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()
|
||||
@@ -7647,6 +7774,62 @@ 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()
|
||||
@@ -8234,7 +8417,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" {
|
||||
@@ -8652,6 +8839,69 @@ 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,
|
||||
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"] == "" {
|
||||
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()
|
||||
|
||||
@@ -63,19 +63,20 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
|
||||
"private-messages": privateMessagesInternalQuerySpec(),
|
||||
"group-messages": groupMessagesInternalQuerySpec(),
|
||||
"user-login-logs": {
|
||||
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
|
||||
Select: `SELECT id,user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
|
||||
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
||||
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
|
||||
},
|
||||
"login-failure-logs": {
|
||||
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
|
||||
Select: `SELECT id,user_id,account_type,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
|
||||
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
||||
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)
|
||||
@@ -256,7 +265,7 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
|
||||
|
||||
func usersInternalQuerySpec() *internalQuerySpec {
|
||||
return &internalQuerySpec{
|
||||
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.account_no AS display_no,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
|
||||
Select: `SELECT u.id AS user_id,u.account_no AS display_no,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
|
||||
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,
|
||||
COALESCE(
|
||||
NULLIF(avatar.location,''),
|
||||
@@ -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"}},
|
||||
@@ -296,9 +305,9 @@ func privateMessagesInternalQuerySpec() *internalQuerySpec {
|
||||
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
|
||||
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.recalled_at IS NOT NULL THEN 'recalled' WHEN m.deletedat IS NOT NULL THEN 'deleted' ELSE 'normal' END`
|
||||
return &internalQuerySpec{
|
||||
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.` + "`from`" + ` AS CHAR) AS from_user_id,
|
||||
Select: `SELECT m.topic,m.seqid AS seq_id,m.` + "`from`" + ` AS from_user_id,
|
||||
COALESCE(su.username,'') AS sender_username,COALESCE(sp.nickname,'') AS sender_nickname,
|
||||
CAST(rs.userid AS CHAR) AS to_user_id,COALESCE(ru.username,'') AS receiver_username,COALESCE(rp.nickname,'') AS receiver_nickname,
|
||||
rs.userid AS to_user_id,COALESCE(ru.username,'') AS receiver_username,COALESCE(rp.nickname,'') AS receiver_nickname,
|
||||
` + contentType + ` AS content_type,` + contentExcerpt + ` AS content_excerpt,
|
||||
` + status + ` AS status,m.createdat AS created_at,m.updatedat AS updated_at`,
|
||||
From: `FROM (SELECT * FROM im_messages WHERE topic LIKE 'p2p%') AS m
|
||||
@@ -318,7 +327,7 @@ func groupMessagesInternalQuerySpec() *internalQuerySpec {
|
||||
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
|
||||
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
|
||||
return &internalQuerySpec{
|
||||
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.from_user AS CHAR) AS from_user_id,
|
||||
Select: `SELECT m.topic,m.seqid AS seq_id,m.from_user AS from_user_id,
|
||||
COALESCE(u.username,'') AS sender_username,COALESCE(p.nickname,'') AS sender_nickname,
|
||||
COALESCE(gs.name,'') AS group_name,m.topic AS group_no,` + contentType + ` AS content_type,
|
||||
` + contentExcerpt + ` AS content_excerpt,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -54,6 +54,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"`
|
||||
@@ -84,10 +92,13 @@ type LoginSession struct {
|
||||
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
|
||||
@@ -132,12 +143,33 @@ 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 := nextMessageSeqWithFloor(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
|
||||
@@ -158,7 +190,7 @@ func TopicLastSeqID(tenantID types.TenantID, topic string) int64 {
|
||||
// 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) (int64, error) {
|
||||
func nextMessageSeqWithFloor(tenantID types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, error) {
|
||||
if baseline < 0 {
|
||||
baseline = 0
|
||||
}
|
||||
@@ -167,7 +199,7 @@ func nextMessageSeqWithFloor(tenantID types.TenantID, topic string, baseline int
|
||||
baseline = floor
|
||||
}
|
||||
}
|
||||
return groupMsgRuntime.NextSeq(tenantID, topic, baseline)
|
||||
return groupMsgRuntime.NextSeq(tenantID, topic, baseline, candidateMonth)
|
||||
}
|
||||
|
||||
// BumpMessageSeqFloor pushes the Redis seq counter forward to seq (never
|
||||
@@ -555,24 +587,129 @@ 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
|
||||
return 0, "", err
|
||||
}
|
||||
return seq, nil
|
||||
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) {
|
||||
@@ -1182,7 +1319,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 {
|
||||
@@ -1276,3 +1425,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, ":")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type testGroupMessageRuntime struct {
|
||||
seqs map[string]int64
|
||||
shortStates map[string]bool
|
||||
deletedPrefixes []string
|
||||
deletedObjects []string
|
||||
}
|
||||
|
||||
func newTestGroupMessageRuntime() *testGroupMessageRuntime {
|
||||
@@ -35,12 +36,24 @@ 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], nil
|
||||
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) {
|
||||
@@ -140,7 +153,8 @@ func (r *testGroupMessageRuntime) GetCachedObject(types.TenantID, string, string
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) DeleteCachedObject(types.TenantID, string, string) error {
|
||||
func (r *testGroupMessageRuntime) DeleteCachedObject(_ types.TenantID, namespace, id string) error {
|
||||
r.deletedObjects = append(r.deletedObjects, namespace+":"+id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -234,9 +248,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)
|
||||
@@ -251,9 +265,9 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +280,7 @@ func TestBumpMessageSeqFloorNeverMovesBackward(t *testing.T) {
|
||||
tenantID := types.TenantID(7)
|
||||
topic := types.Uid(1).P2PName(types.Uid(2))
|
||||
|
||||
seq, ready, err := ReserveMessageSeq(tenantID, topic, 0)
|
||||
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)
|
||||
}
|
||||
@@ -283,7 +297,7 @@ func TestBumpMessageSeqFloorNeverMovesBackward(t *testing.T) {
|
||||
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 {
|
||||
if seq, _, _, err := ReserveMessageSeq(tenantID, topic, 0, ""); err != nil || seq != 6 {
|
||||
t.Fatalf("unexpected seq after bump: seq=%d err=%v", seq, err)
|
||||
}
|
||||
}
|
||||
@@ -299,3 +313,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()
|
||||
|
||||
+80
-6
@@ -1079,6 +1079,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 +1236,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 +1271,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 +1402,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 +1652,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)
|
||||
@@ -1838,6 +1856,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 == "" {
|
||||
@@ -1905,6 +1933,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
|
||||
}
|
||||
@@ -1945,6 +1974,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
|
||||
}
|
||||
@@ -2007,6 +2037,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
|
||||
@@ -2076,8 +2117,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
|
||||
}
|
||||
@@ -2097,6 +2139,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
|
||||
}
|
||||
|
||||
@@ -2141,6 +2184,7 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
|
||||
// object so a later Topics.Get sees the new owner.
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic)
|
||||
invalidateGroupMemberPages(tenantID, topic)
|
||||
invalidateGroupSettings(tenantID, topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3008,9 +3052,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
|
||||
@@ -3589,6 +3632,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 {
|
||||
@@ -3605,7 +3649,7 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
|
||||
return nil, types.ErrDuplicate
|
||||
}
|
||||
}
|
||||
seq, err := nextMessageSeqWithFloor(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)
|
||||
@@ -3613,6 +3657,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
|
||||
@@ -3631,6 +3679,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
|
||||
@@ -3638,7 +3687,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)
|
||||
@@ -3677,6 +3725,7 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
|
||||
}
|
||||
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())
|
||||
}
|
||||
@@ -3716,6 +3765,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)
|
||||
}
|
||||
@@ -3778,6 +3832,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
|
||||
@@ -4026,6 +4081,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.
|
||||
@@ -4080,6 +4140,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1115,6 +1115,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) {
|
||||
|
||||
Reference in New Issue
Block a user