fix: invalidate group settings cache on membership changes

This commit is contained in:
richjohnrambo
2026-09-16 01:03:40 +08:00
parent 139681fadb
commit 9626e8ee7c
5 changed files with 393 additions and 7 deletions
+116 -4
View File
@@ -58,6 +58,11 @@ type audit struct {
usernameB string usernameB string
phoneB string phoneB string
passB string passB string
tokenC string
userC string
usernameC string
phoneC string
passC string
groupTopic string groupTopic string
groupVer int groupVer int
groupQR string groupQR string
@@ -110,6 +115,7 @@ func (a *audit) run() {
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000) suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123" a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123"
a.usernameB, a.phoneB, a.passB = "b"+suffix, "156"+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 { 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")) return fmt.Sprintf("tenant=%v endpoints=%v", dig(r.Data, "tenant", "code"), dig(r.Data, "endpoints", "api"))
@@ -217,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.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.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-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) announcementID := ""
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", 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}, func(r imResp) string {
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 = 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", "管理员数量限制", "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.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.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) a.testHTTP("6", "6.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil)
@@ -309,6 +363,17 @@ func (a *audit) run() {
return "category=" + a.feedbackCat 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("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) a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
} }
@@ -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) { func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) {
u, _ := url.Parse(a.wsURL) u, _ := url.Parse(a.wsURL)
q := u.Query() 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) { 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)} 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 { if err != nil {
r.Reason = err.Error() 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) { 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 var reader io.Reader
if body != nil { if body != nil {
b, _ := json.Marshal(body) b, _ := json.Marshal(body)
@@ -458,6 +552,9 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
if idem { if idem {
req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano())) 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) resp, err := a.client.Do(req)
if err != nil { if err != nil {
return 0, imResp{}, "", err 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 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 { func (a *audit) report() string {
var b strings.Builder var b strings.Builder
now := time.Now().Format("2006-01-02 15:04:05 -0700") now := time.Now().Format("2006-01-02 15:04:05 -0700")
+168 -1
View File
@@ -55,6 +55,8 @@ type audit struct {
p2pTopic string p2pTopic string
bobTopic string bobTopic string
groupTopic string groupTopic string
groupSeq int
groupFile string
fileURL string fileURL string
messageID string messageID string
} }
@@ -330,9 +332,26 @@ func (a *audit) run() {
return "topic=" + topic return "topic=" + topic
}) })
a.step("WS-21", "A/B 订阅群 Topic", "WS", a.groupTopic, func() string { 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" 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 { if err := alice.sub(a.groupTopic); err != nil {
return "FAIL: A sub group: " + err.Error() return "FAIL: A sub group: " + err.Error()
} }
@@ -350,6 +369,112 @@ func (a *audit) run() {
if err != nil { if err != nil {
return "FAIL: " + err.Error() 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) return fmt.Sprintf("seq=%d topic=%s", seq, topic)
}) })
a.step("WS-23", "get 群 desc/sub/data", "WS", a.groupTopic, func() string { a.step("WS-23", "get 群 desc/sub/data", "WS", a.groupTopic, func() string {
@@ -369,6 +494,43 @@ func (a *audit) run() {
a.fileURL = fileURL a.fileURL = fileURL
return "url=" + mask(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 { a.step("WS-25", "WS 文件消息下发", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil || a.fileURL == "" { if alice == nil || bob == nil || a.fileURL == "" {
return "FAIL: websocket client or file missing" 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 { func mask(s string) string {
if len(s) <= 8 { if len(s) <= 8 {
return s return s
@@ -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”闭环。
+15 -1
View File
@@ -13,6 +13,7 @@ type testGroupMessageRuntime struct {
seqs map[string]int64 seqs map[string]int64
shortStates map[string]bool shortStates map[string]bool
deletedPrefixes []string deletedPrefixes []string
deletedObjects []string
} }
func newTestGroupMessageRuntime() *testGroupMessageRuntime { func newTestGroupMessageRuntime() *testGroupMessageRuntime {
@@ -152,7 +153,8 @@ func (r *testGroupMessageRuntime) GetCachedObject(types.TenantID, string, string
return false, nil 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 return nil
} }
@@ -311,3 +313,15 @@ func TestInvalidateGroupMemberPagesUsesTopicKeyPrefix(t *testing.T) {
t.Fatalf("unexpected deleted prefixes: %#v", runtime.deletedPrefixes) 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)
}
}
+16 -1
View File
@@ -1856,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) { func invalidateGroupMemberObjects(tenantID types.TenantID, topic string) {
topic = strings.TrimSpace(topic) topic = strings.TrimSpace(topic)
if topic == "" { if topic == "" {
@@ -1923,6 +1933,7 @@ func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationI
invitation, err := tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now) invitation, err := tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
if err == nil && invitation != nil { if err == nil && invitation != nil {
invalidateGroupMemberPages(tenantID, invitation.Topic) invalidateGroupMemberPages(tenantID, invitation.Topic)
invalidateGroupSettings(tenantID, invitation.Topic)
} }
return invitation, err return invitation, err
} }
@@ -1963,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) 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 { if err == nil && resolved != nil && resolved.Status == types.GroupJoinRequestStatusAccepted {
invalidateGroupMemberPages(tenantID, topic) invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
} }
return resolved, err return resolved, err
} }
@@ -2105,8 +2117,9 @@ func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, i
} }
created, err := tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target, created, err := tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now) strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
if err == nil { if err == nil && created {
invalidateGroupMemberPages(tenantID, topic) invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
} }
return created, err return created, err
} }
@@ -2126,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, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true))
invalidateGroupMemberPages(tenantID, topic) invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
return nil return nil
} }
@@ -2170,6 +2184,7 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
// object so a later Topics.Get sees the new owner. // object so a later Topics.Get sees the new owner.
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic) _ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic)
invalidateGroupMemberPages(tenantID, topic) invalidateGroupMemberPages(tenantID, topic)
invalidateGroupSettings(tenantID, topic)
return nil return nil
} }