diff --git a/cmd/imtestaudit/main.go b/cmd/imtestaudit/main.go index 34f731d..1638316 100644 --- a/cmd/imtestaudit/main.go +++ b/cmd/imtestaudit/main.go @@ -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")) @@ -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.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) @@ -309,6 +363,17 @@ 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) } @@ -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") diff --git a/cmd/imwsaudit/main.go b/cmd/imwsaudit/main.go index 719c4a0..8f6f37f 100644 --- a/cmd/imwsaudit/main.go +++ b/cmd/imwsaudit/main.go @@ -55,6 +55,8 @@ type audit struct { p2pTopic string bobTopic string groupTopic string + groupSeq int + groupFile string fileURL string messageID string } @@ -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 diff --git a/docs/test/IM-群聊测试环境验收报告-20260916.md b/docs/test/IM-群聊测试环境验收报告-20260916.md new file mode 100644 index 0000000..606fde3 --- /dev/null +++ b/docs/test/IM-群聊测试环境验收报告-20260916.md @@ -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”闭环。 diff --git a/server/store/group_message_runtime_test.go b/server/store/group_message_runtime_test.go index 658db78..324f79d 100644 --- a/server/store/group_message_runtime_test.go +++ b/server/store/group_message_runtime_test.go @@ -13,6 +13,7 @@ type testGroupMessageRuntime struct { seqs map[string]int64 shortStates map[string]bool deletedPrefixes []string + deletedObjects []string } func newTestGroupMessageRuntime() *testGroupMessageRuntime { @@ -152,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 } @@ -311,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) + } +} diff --git a/server/store/store.go b/server/store/store.go index 1c6d95d..54d66c2 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -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) { topic = strings.TrimSpace(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) if err == nil && invitation != nil { invalidateGroupMemberPages(tenantID, invitation.Topic) + invalidateGroupSettings(tenantID, invitation.Topic) } 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) if err == nil && resolved != nil && resolved.Status == types.GroupJoinRequestStatusAccepted { invalidateGroupMemberPages(tenantID, topic) + invalidateGroupSettings(tenantID, topic) } 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, strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now) - if err == nil { + if err == nil && created { invalidateGroupMemberPages(tenantID, topic) + invalidateGroupSettings(tenantID, topic) } 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, true)) invalidateGroupMemberPages(tenantID, topic) + invalidateGroupSettings(tenantID, topic) return nil } @@ -2170,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 }