Files
im_server/cmd/imtestaudit/main.go
T

612 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", "MeetingIM 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&current_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-MeetingIM-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-MeetingIM-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
}