package main import ( "bytes" "context" "crypto/tls" "encoding/base64" "encoding/json" "flag" "fmt" "io" "net/http" "net/url" "os" "sort" "strings" "time" "github.com/gorilla/websocket" ) type imResp struct { Code int `json:"code"` Reason string `json:"reason"` Message string `json:"message"` Data map[string]any `json:"data"` RequestID string `json:"request_id"` } type result struct { Chapter string ID string Name string Method string Path string Status int Code int Reason string Pass bool Note string Body string } type audit struct { baseURL string wsURL string apiKey string tenantCode string client *http.Client results []result tokenA string userA string usernameA string phoneA string passA string tokenB string userB string usernameB string phoneB string passB string groupTopic string groupVer int groupQR string friendReq string p2pTopic string meQR string meQRPurpose string meetingID string meetingVer int dailyID string dailyVer int monthlyID string monthlyVer int mailboxNoticeVersion string actionTok string abuseReason string profileVer int privacyVer int feedbackCat string } func main() { baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL") wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL") apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key") tenant := flag.String("tenant", "default", "tenant code") out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path") flag.Parse() a := &audit{ baseURL: strings.TrimRight(*baseURL, "/"), wsURL: *wsURL, apiKey: *apiKey, tenantCode: *tenant, client: &http.Client{ Timeout: 20 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, }, }, } a.run() if err := os.WriteFile(*out, []byte(a.report()), 0o644); err != nil { panic(err) } fmt.Printf("wrote %s\n", *out) } 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.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")) }) a.register("1", "1.2A", a.usernameA, a.phoneA, a.passA, true) a.register("1", "1.2B", a.usernameB, a.phoneB, a.passB, false) a.testHTTP("1", "1.6a", "忘记密码发码", "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "reset_password", "phone": a.phoneA}, "", true, []int{200}, func(r imResp) string { challenge := asString(r.Data["challenge_id"]) if challenge == "" { return "未返回 challenge_id" } a.testHTTP("1", "1.6b", "忘记密码验证码校验", "POST", "/v1/im/auth/password-reset/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(rr imResp) string { return "reset_token=" + mask(asString(rr.Data["reset_token"])) }) return "challenge_id=" + mask(challenge) }) a.testHTTP("1", "1.7a", "二次身份验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": "close_youth_mode", "channel": "sms"}, a.tokenA, true, []int{200}, func(r imResp) string { challenge := asString(r.Data["challenge_id"]) if challenge == "" { challenge = asString(r.Data["id"]) } if challenge != "" { a.testHTTP("1", "1.7b", "二次身份验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, a.tokenA, true, []int{200}, func(rr imResp) string { a.actionTok = asString(rr.Data["action_token"]) return "action_token=" + mask(a.actionTok) }) } return "challenge_id=" + mask(challenge) }) a.testHTTP("1", "1.8", "实名状态查询", "GET", "/v1/im/security/realname", nil, a.tokenA, false, []int{200}, nil) 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) }) a.testHTTP("2", "2.search", "会话搜索", "GET", "/v1/im/search/conversations?q=&cursor=", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("2", "2.sys", "系统通知列表", "GET", "/v1/im/system-notifications?limit=20", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("2", "2.sys-read", "系统通知已读不存在 ID", "POST", "/v1/im/system-notifications/nonexistent/read", map[string]any{"read_at": time.Now().UTC().Format(time.RFC3339)}, a.tokenA, true, []int{200, 404}, nil) a.testHTTP("5", "5.search", "用户搜索 B", "GET", "/v1/im/users/search?q="+url.QueryEscape(a.usernameB)+"&type=account&cursor=", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("5", "5.profile", "查看 B 资料", "GET", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/profile", nil, a.tokenA, false, []int{200, 403, 404}, nil) a.testHTTP("5", "5.user-qr", "查看 B 加好友二维码", "POST", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200, 403, 404}, nil) a.testHTTP("5", "5.friend-create", "创建好友申请 A->B", "POST", "/v1/im/friend-requests", map[string]any{"target_user_id": a.userB, "message": "联调好友申请", "source": "search"}, a.tokenA, true, []int{200}, func(r imResp) string { a.friendReq = asString(r.Data["request_id"]) return "request_id=" + a.friendReq }) if a.friendReq != "" { a.testHTTP("5", "5.friend-accept", "B 同意好友申请", "POST", "/v1/im/friend-requests/"+url.PathEscape(a.friendReq)+"/accept", map[string]any{"version": 1}, a.tokenB, true, []int{200, 409}, func(r imResp) string { a.p2pTopic = asString(r.Data["topic"]) return "topic=" + a.p2pTopic }) } a.testHTTP("3", "3.context", "P2P 会话上下文", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/context", nil, a.tokenA, false, []int{200, 400, 404}, nil) a.testHTTP("3", "3.p2p-create", "创建 P2P 会话目标接口", "POST", "/v1/im/conversations/p2p", map[string]any{"peer_user_id": a.userB}, a.tokenA, true, []int{200, 404, 405}, nil) a.testHTTP("3", "3.conv-settings", "会话设置查询", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/settings", nil, a.tokenA, false, []int{200, 404, 405}, nil) a.testHTTP("3", "3.conv-messages", "会话消息列表", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/messages?since=1&limit=20", nil, a.tokenA, false, []int{200, 404, 405}, nil) a.testHTTP("4", "4.forward-targets", "转发目标列表", "GET", "/v1/im/forward-targets?query=&cursor=", nil, a.tokenA, false, []int{200}, nil) 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.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) a.testHTTP("5", "5.requests", "好友申请列表", "GET", "/v1/im/friend-requests?direction=inbound&status=all&cursor=", nil, a.tokenB, false, []int{200}, nil) a.testHTTP("5", "5.blocks", "黑名单列表", "GET", "/v1/im/blocks?cursor=", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("5", "5.block-put", "拉黑 B", "PUT", "/v1/im/blocks/"+url.PathEscape(a.userB), map[string]any{}, a.tokenA, true, []int{200}, nil) a.testHTTP("5", "5.block-del", "解除拉黑 B", "DELETE", "/v1/im/blocks/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil) a.testHTTP("5", "5.me-qr", "我的二维码", "POST", "/v1/im/me/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200}, func(r imResp) string { a.meQR = asString(r.Data["qr_token"]) a.meQRPurpose = "add_friend" return "qr_token=" + mask(a.meQR) }) if a.meQR != "" { a.testHTTP("5", "5.qr-resolve", "二维码解析", "POST", "/v1/im/qrcodes/resolve", map[string]any{"token": a.meQR, "purpose": a.meQRPurpose}, a.tokenB, true, []int{200}, nil) } a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "联调群" + suffix, "description": "自动化测试群", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string { a.groupTopic = asString(r.Data["topic"]) a.groupVer = asInt(r.Data["version"]) return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer) }) groupPath := "/v1/im/groups/" + url.PathEscape(a.groupTopic) a.testHTTP("6", "6.list", "群列表", "GET", "/v1/im/groups?category=owned&query=&cursor=", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("6", "6.detail", "群详情", "GET", groupPath, nil, a.tokenA, false, []int{200, 404}, nil) a.testHTTP("6", "6.patch", "更新群资料", "PATCH", groupPath, map[string]any{"description": "自动化测试群已更新", "version": a.groupVer}, a.tokenA, true, []int{200, 409, 404}, func(r imResp) string { if asInt(r.Data["version"]) > 0 { a.groupVer = asInt(r.Data["version"]) } return fmt.Sprintf("version=%d", a.groupVer) }) a.testHTTP("6", "6.settings", "群设置", "GET", groupPath+"/settings", nil, a.tokenA, false, []int{200, 404}, nil) a.testHTTP("6", "6.member-settings", "群个人设置", "PATCH", groupPath+"/member-settings", map[string]any{"pinned": true, "mute_notifications": false}, a.tokenA, true, []int{200, 409, 404}, nil) a.testHTTP("6", "6.my-profile", "群内昵称", "PUT", groupPath+"/members/me/profile", map[string]any{"nickname": "联调昵称", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil) a.testHTTP("6", "6.members", "群成员", "GET", groupPath+"/members?query=&cursor=&mentionable=true", nil, a.tokenA, false, []int{200, 404}, nil) a.testHTTP("6", "6.member-profile", "群成员资料", "GET", groupPath+"/members/"+url.PathEscape(a.userB)+"/profile", nil, a.tokenA, false, []int{200, 404}, nil) a.testHTTP("6", "6.qrcode", "群二维码", "POST", groupPath+"/qrcodes", map[string]any{"expires_in_seconds": 86400}, a.tokenA, true, []int{200, 404}, func(r imResp) string { a.groupQR = asString(r.Data["qr_token"]) return "qr_token=" + mask(a.groupQR) }) a.testHTTP("6", "6.invite", "群邀请", "POST", groupPath+"/invitations", map[string]any{"user_ids": []string{a.userB}, "message": "邀请入群"}, a.tokenA, true, []int{200, 409, 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.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) a.testHTTP("6", "6.role-limits", "管理员数量限制", "GET", groupPath+"/role-limits", 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.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil) a.testHTTP("7", "7.favorites", "收藏列表", "GET", "/v1/im/favorites?type=all&q=&cursor=", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("7", "7.favorite-batch", "批量收藏不存在消息", "POST", "/v1/im/favorites/batch", map[string]any{"messages": []map[string]any{{"topic": a.groupTopic, "seq": 1}}}, a.tokenA, true, []int{200, 400, 403, 404}, nil) a.testHTTP("7", "7.message-search", "聊天记录搜索", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q=%E8%81%94%E8%B0%83&type=text&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil) a.testHTTP("7", "7.media", "会话媒体列表", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/media?type=image&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil) a.testHTTP("7", "7.msg-context", "消息上下文", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/1/context?before=20&after=20", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil) a.testHTTP("7", "7.file-download", "文件下载不存在文件", "GET", "/v0/file/s/nonexistent", nil, a.tokenA, false, []int{200, 400, 401, 403, 404}, nil) a.testHTTP("8", "8.calls", "通话记录列表", "GET", "/v1/im/calls?limit=5", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("8", "8.topic-calls", "会话通话记录", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/calls?limit=5", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil) a.testHTTP("9", "9.workbench", "工作台", "GET", "/v1/im/workbench", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("9", "9.depts", "组织部门", "GET", "/v1/im/organization/departments?parent_id=&cursor=", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("9", "9.org-search-empty", "组织空关键词搜索", "GET", "/v1/im/organization/search?q=", nil, a.tokenA, false, []int{200, 400}, nil) start := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute) end := start.Add(time.Hour) a.testHTTP("9", "9.meeting-create", "创建会议", "POST", "/v1/im/meetings", map[string]any{"title": "联调会议", "start_at": start.Format(time.RFC3339), "end_at": end.Format(time.RFC3339), "location": "线上", "attendee_user_ids": []string{a.userB}, "reminder_minutes": []int{10}}, a.tokenA, true, []int{200}, func(r imResp) string { a.meetingID = asString(r.Data["meeting_id"]) a.meetingVer = asInt(r.Data["version"]) return fmt.Sprintf("meeting_id=%s version=%d", a.meetingID, a.meetingVer) }) a.testHTTP("9", "9.meeting-list", "会议列表", "GET", "/v1/im/meetings?limit=5", nil, a.tokenA, false, []int{200, 405}, nil) if a.meetingID != "" { a.testHTTP("9", "9.meeting-get", "会议详情", "GET", "/v1/im/meetings/"+url.PathEscape(a.meetingID), nil, a.tokenA, false, []int{200, 404}, nil) } today := time.Now().UTC().Format("2006-01-02") a.testHTTP("9", "9.daily-list", "日报列表", "GET", "/v1/im/reports/daily?limit=5", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("9", "9.daily-create", "创建日报草稿", "POST", "/v1/im/reports/daily", map[string]any{"report_date": today}, a.tokenA, true, []int{200, 409}, func(r imResp) string { a.dailyID = asString(r.Data["report_id"]) a.dailyVer = asInt(r.Data["version"]) return fmt.Sprintf("report_id=%s version=%d", a.dailyID, a.dailyVer) }) a.testHTTP("9", "9.daily-stats", "日报统计", "GET", "/v1/im/reports/daily/statistics?date="+today, nil, a.tokenA, false, []int{200}, nil) month := time.Now().UTC().Format("2006-01") a.testHTTP("9", "9.monthly-list", "月报列表", "GET", "/v1/im/reports/monthly?limit=5", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("9", "9.monthly-create", "创建月报草稿", "POST", "/v1/im/reports/monthly", map[string]any{"month": month}, a.tokenA, true, []int{200, 409}, func(r imResp) string { a.monthlyID = asString(r.Data["report_id"]) a.monthlyVer = asInt(r.Data["version"]) return fmt.Sprintf("report_id=%s version=%d", a.monthlyID, a.monthlyVer) }) a.testHTTP("9", "9.monthly-stats", "月报统计", "GET", "/v1/im/reports/monthly/statistics?month="+month, nil, a.tokenA, false, []int{200}, nil) a.testHTTP("9", "9.mailbox-config", "总经理信箱配置", "GET", "/v1/im/executive-mailbox/config", nil, a.tokenA, false, []int{200}, func(r imResp) string { a.mailboxNoticeVersion = asString(dig(r.Data, "notice", "version")) return "notice_version=" + a.mailboxNoticeVersion }) a.testHTTP("9", "9.mailbox-submit", "总经理信箱提交", "POST", "/v1/im/executive-mailbox/submissions", map[string]any{"subject": "联调建议", "content": "自动化测试提交", "anonymous": false, "attachment_file_ids": []string{}, "notice_version": a.mailboxNoticeVersion}, a.tokenA, true, []int{200, 400, 403}, nil) a.testHTTP("10", "10.me", "我的账号", "GET", "/v1/im/me", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("10", "10.profile", "个人资料", "GET", "/v1/im/me/profile", nil, a.tokenA, false, []int{200}, func(r imResp) string { a.profileVer = asInt(r.Data["version"]) return fmt.Sprintf("version=%d", a.profileVer) }) a.testHTTP("10", "10.profile-patch", "更新个人资料", "PATCH", "/v1/im/me/profile", map[string]any{"nickname": "联调用户A", "signature": "audit", "version": a.profileVer}, a.tokenA, true, []int{200, 409}, nil) a.testHTTP("10", "10.privacy", "隐私设置", "GET", "/v1/im/me/privacy", nil, a.tokenA, false, []int{200}, func(r imResp) string { a.privacyVer = asInt(r.Data["version"]) return fmt.Sprintf("version=%d", a.privacyVer) }) a.testHTTP("10", "10.privacy-patch", "更新隐私设置", "PATCH", "/v1/im/me/privacy", map[string]any{"add_me_methods": map[string]any{"account": true, "phone": true, "qr": true, "group": true}, "profile_visibility": "everyone", "last_seen_visibility": "friends", "read_receipts": true, "version": a.privacyVer}, a.tokenA, true, []int{200, 409}, nil) a.testHTTP("10", "10.youth", "青少年模式", "GET", "/v1/im/settings/youth-mode", nil, a.tokenA, false, []int{200}, nil) a.testHTTP("10", "10.independent-verify", "独立密码验证", "POST", "/v1/im/settings/independent-password/verify", map[string]any{"password": "1234", "purpose": "youth_mode"}, a.tokenA, true, []int{200, 400, 403}, nil) a.testHTTP("10", "10.push", "设备 Push Token", "PUT", "/v1/im/devices/audit-device-"+suffix+"/push-token", map[string]any{"provider": "apns", "token": "audit-token-" + suffix, "platform": "ios", "app_version": "1.0.0"}, a.tokenA, true, []int{200}, nil) a.testHTTP("11", "11.url", "URL 风险检查", "POST", "/v1/im/security/urls/check", map[string]any{"url": "https://example.com"}, a.tokenA, true, []int{200}, nil) a.testHTTP("11", "11.abuse-reasons", "举报原因", "GET", "/v1/im/reports/abuse/reasons?target_type=user", nil, a.tokenA, false, []int{200}, func(r imResp) string { if items, ok := r.Data["items"].([]any); ok && len(items) > 0 { if first, ok := items[0].(map[string]any); ok { a.abuseReason = asString(first["reason_code"]) if a.abuseReason == "" { a.abuseReason = asString(first["code"]) } } } return "reason=" + a.abuseReason }) a.testHTTP("11", "11.abuse-submit", "提交举报", "POST", "/v1/im/reports/abuse", map[string]any{"target_type": "user", "target_id": a.userB, "reason_code": a.abuseReason, "description": "联调举报", "evidence": []map[string]any{}}, a.tokenA, true, []int{200, 400, 403}, nil) a.testHTTP("11", "11.feedback-config", "反馈配置", "GET", "/v1/im/feedback/config", nil, a.tokenA, false, []int{200}, func(r imResp) string { a.feedbackCat = "bug" if items, ok := r.Data["categories"].([]any); ok && len(items) > 0 { if s := asString(items[0]); s != "" { a.feedbackCat = s } } 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) } func (a *audit) register(chapter, id, username, phone, password string, primary bool) { 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 hi+acc 注册 " + username, Method: "WS", Path: "/v0/channels", Status: 0, Code: 0, Pass: err == nil, Note: note} if err != nil { res.Reason = err.Error() } a.results = append(a.results, res) if primary { a.tokenA, a.userA = token, userID } else { a.tokenB, a.userB = token, userID } } func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) { u, _ := url.Parse(a.wsURL) q := u.Query() q.Set("apikey", a.apiKey) u.RawQuery = q.Encode() conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) if err != nil { return "", "", "", err } defer conn.Close() deadline := time.Now().Add(15 * time.Second) _ = conn.SetReadDeadline(deadline) _ = conn.SetWriteDeadline(deadline) hiID := "hi-" + username if err = conn.WriteJSON(map[string]any{"hi": map[string]any{"id": hiID, "ver": "0.25", "ua": "imtestaudit/1.0", "platf": "go", "lang": "zh-CN", "tenant": a.tenantCode}}); err != nil { return "", "", "", err } if _, err = waitCtrl(conn, hiID); err != nil { return "", "", "", fmt.Errorf("hi: %w", err) } accID := "acc-" + username secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) err = conn.WriteJSON(map[string]any{"acc": map[string]any{ "id": accID, "user": "new", "scheme": "basic", "username": username, "phone": phone, "secret": secret, "login": true, "cred": []map[string]any{{ "meth": "tel", "val": phone, "resp": verification, "params": map[string]any{"challenge_id": challenge}, }}, "desc": map[string]any{"public": map[string]any{"fn": username}}, }}) if err != nil { return "", "", "", err } ctrl, err := waitCtrl(conn, accID) if err != nil { return "", "", "", fmt.Errorf("acc: %w", err) } params, _ := ctrl["params"].(map[string]any) token := asString(params["token"]) user := asString(params["user"]) return token, user, "user=" + user + " token=" + mask(token), nil } func waitCtrl(conn *websocket.Conn, id string) (map[string]any, error) { for i := 0; i < 20; i++ { var msg map[string]any if err := conn.ReadJSON(&msg); err != nil { return nil, err } ctrl, ok := msg["ctrl"].(map[string]any) if !ok || asString(ctrl["id"]) != id { continue } code := asInt(ctrl["code"]) if code < 200 || code >= 300 { return ctrl, fmt.Errorf("ctrl code=%d text=%s", code, asString(ctrl["text"])) } return ctrl, nil } return nil, fmt.Errorf("ctrl %s not received", id) } 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) 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() } else { for _, s := range want { if status == s { r.Pass = true break } } if note != nil { r.Note = note(parsed) } } a.results = append(a.results, r) } func (a *audit) call(method, path string, body any, token string, idem bool) (int, imResp, string, error) { var reader io.Reader if body != nil { b, _ := json.Marshal(body) reader = bytes.NewReader(b) } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() target := path if strings.HasPrefix(path, "/") { target = a.baseURL + path } req, err := http.NewRequestWithContext(ctx, method, target, reader) if err != nil { return 0, imResp{}, "", err } req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")) req.Header.Set("X-Tenant-Code", a.tenantCode) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) if strings.HasPrefix(path, "/v0/file/") { req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token))) } } if idem { req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano())) } resp, err := a.client.Do(req) if err != nil { return 0, imResp{}, "", err } defer resp.Body.Close() b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) var parsed imResp _ = json.Unmarshal(b, &parsed) return resp.StatusCode, parsed, string(b), nil } func (a *audit) report() string { var b strings.Builder now := time.Now().Format("2006-01-02 15:04:05 -0700") fmt.Fprintf(&b, "# IM 前后端 1-11 章测试环境接口测试结果(2026-08-20)\n\n") fmt.Fprintf(&b, "## 1. 测试范围\n\n") fmt.Fprintf(&b, "- 对照文档:`docs/IM-前后端对接文档.md`\n") fmt.Fprintf(&b, "- 测试时间:`%s`\n", now) fmt.Fprintf(&b, "- 测试环境:`%s`\n", a.baseURL) fmt.Fprintf(&b, "- WebSocket:`%s`\n", a.wsURL) fmt.Fprintf(&b, "- 租户:`%s`\n", a.tenantCode) fmt.Fprintf(&b, "- 测试账号 A:`%s` / `%s` / `%s`\n", a.usernameA, a.phoneA, a.userA) fmt.Fprintf(&b, "- 测试账号 B:`%s` / `%s` / `%s`\n", a.usernameB, a.phoneB, a.userB) fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n") pass, fail := 0, 0 chapters := map[string][2]int{} for _, r := range a.results { pair := chapters[r.Chapter] if r.Pass { pass++ pair[0]++ } else { fail++ pair[1]++ } chapters[r.Chapter] = pair } fmt.Fprintf(&b, "## 2. 总体结论\n\n") fmt.Fprintf(&b, "本轮按前后端对接文档从第 1 章到第 11 章直接访问测试环境接口,共执行 `%d` 项,符合预期 `%d` 项,不符合预期 `%d` 项。符合预期不等于业务全链路完全验收;部分接口以 404/405/409/403 作为当前文档标注的未实现、无数据或权限分支记录。\n\n", len(a.results), pass, fail) fmt.Fprintf(&b, "| 章 | 符合预期 | 不符合预期 |\n| --- | ---: | ---: |\n") keys := make([]string, 0, len(chapters)) for k := range chapters { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { return chapterOrder(keys[i]) < chapterOrder(keys[j]) }) for _, k := range keys { pair := chapters[k] fmt.Fprintf(&b, "| %s | %d | %d |\n", k, pair[0], pair[1]) } fmt.Fprintf(&b, "\n## 3. 需要关注的问题\n\n") hasFail := false for _, r := range a.results { if !r.Pass { hasFail = true fmt.Fprintf(&b, "- `%s` %s `%s`:HTTP `%d`,code `%d`,reason `%s`。%s\n", r.ID, r.Method, r.Path, r.Status, r.Code, r.Reason, r.Note) } } if !hasFail { fmt.Fprintf(&b, "- 本轮自动化黑盒测试未发现非预期失败。\n") } fmt.Fprintf(&b, "\n## 4. 分章明细\n\n") for _, k := range keys { fmt.Fprintf(&b, "### 第 %s 章\n\n", k) fmt.Fprintf(&b, "| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |\n| --- | --- | --- | --- | ---: | --- | --- | --- |\n") for _, r := range a.results { if r.Chapter != k { continue } status := "通过" if !r.Pass { status = "未通过" } reason := strings.TrimSpace(fmt.Sprintf("%d/%s", r.Code, r.Reason)) fmt.Fprintf(&b, "| `%s` | %s | `%s` | `%s` | %d | `%s` | %s | %s |\n", r.ID, esc(r.Name), r.Method, esc(r.Path), r.Status, esc(reason), status, esc(r.Note)) } fmt.Fprintf(&b, "\n") } fmt.Fprintf(&b, "## 5. 测试产生的数据\n\n") fmt.Fprintf(&b, "- 用户 A:`%s`,username `%s`,phone `%s`\n", a.userA, a.usernameA, a.phoneA) fmt.Fprintf(&b, "- 用户 B:`%s`,username `%s`,phone `%s`\n", a.userB, a.usernameB, a.phoneB) fmt.Fprintf(&b, "- 好友申请:`%s`\n", a.friendReq) fmt.Fprintf(&b, "- P2P Topic:`%s`\n", a.p2pTopic) fmt.Fprintf(&b, "- 群 Topic:`%s`\n", a.groupTopic) fmt.Fprintf(&b, "- 会议:`%s`\n", a.meetingID) fmt.Fprintf(&b, "- 日报草稿:`%s`\n", a.dailyID) fmt.Fprintf(&b, "- 月报草稿:`%s`\n", a.monthlyID) fmt.Fprintf(&b, "\n这些数据位于测试环境租户 `%s`,可按需清理。\n", a.tenantCode) return b.String() } func asString(v any) string { switch t := v.(type) { case string: return t default: return "" } } func asInt(v any) int { switch t := v.(type) { case int: return t case float64: return int(t) case json.Number: n, _ := t.Int64() return int(n) default: return 0 } } func dig(m map[string]any, path ...string) any { var cur any = m for _, p := range path { obj, ok := cur.(map[string]any) if !ok { return nil } cur = obj[p] } return cur } func mask(s string) string { if len(s) <= 8 { return s } return s[:4] + "..." + s[len(s)-4:] } func compact(s string) string { s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) if len(s) > 220 { return s[:220] + "..." } return s } func esc(s string) string { s = strings.ReplaceAll(s, "|", "\\|") s = strings.ReplaceAll(s, "\n", " ") return s } func chapterOrder(s string) int { var n int _, _ = fmt.Sscanf(s, "%d", &n) return n }