fix: stabilize test environment IM endpoints

This commit is contained in:
richjohnrambo
2026-08-20 23:06:54 +08:00
parent 6c6d2f6d3b
commit 2b3480fa5f
4 changed files with 923 additions and 14 deletions
+607
View File
@@ -0,0 +1,607 @@
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
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&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}, nil)
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": "v1"}, 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
}
@@ -0,0 +1,210 @@
# IM 前后端 1-11 章测试环境接口测试结果(2026-08-20)
## 1. 测试范围
- 对照文档:`docs/IM-前后端对接文档.md`
- 测试时间:`2026-08-20 22:59:05 +0800`
- 测试环境:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels`
- 租户:`default`
- 测试账号 A:`a35758000` / `15535758000` / `usrv5HSlKE-PQk`
- 测试账号 B:`b35758000` / `15635758000` / `usrmHgqblBLy8Q`
- API Key 与 Token 已脱敏,不在文档中展开。
## 2. 总体结论
本轮按前后端对接文档从第 1 章到第 11 章直接访问测试环境接口,共执行 `96` 项,符合预期 `85` 项,不符合预期 `11` 项。符合预期不等于业务全链路完全验收;部分接口以 404/405/409/403 作为当前文档标注的未实现、无数据或权限分支记录。
| 章 | 符合预期 | 不符合预期 |
| --- | ---: | ---: |
| 1 | 12 | 2 |
| 2 | 4 | 0 |
| 3 | 2 | 2 |
| 4 | 5 | 1 |
| 5 | 12 | 0 |
| 6 | 19 | 2 |
| 7 | 5 | 1 |
| 8 | 2 | 0 |
| 9 | 12 | 2 |
| 10 | 8 | 0 |
| 11 | 4 | 1 |
## 3. 需要关注的问题
- `1.6a` POST `/v1/im/auth/verification-codes`:HTTP `502`,code `0`,reason ``。未返回 challenge_id
- `1.7a` POST `/v1/im/security/challenges`:HTTP `502`,code `0`,reason ``。challenge_id=
- `3.conv-settings` GET `/v1/im/conversations/p2pv5HSlKE-PQmYeCpuUEvLxA/settings`:HTTP `502`,code `0`,reason ``。
- `3.conv-messages` GET `/v1/im/conversations/p2pv5HSlKE-PQmYeCpuUEvLxA/messages?since=1&limit=20`:HTTP `502`,code `0`,reason ``。
- `4.forward-targets` GET `/v1/im/forward-targets?query=&cursor=`:HTTP `404`,code `0`,reason ``。
- `6.ann-latest` GET `/v1/im/groups/grppe4qw1ad4QQ/announcements/latest`:HTTP `500`,code `90000`,reason `INTERNAL_ERROR`。
- `6.ann-put` PUT `/v1/im/groups/grppe4qw1ad4QQ/announcements/current`:HTTP `500`,code `90000`,reason `INTERNAL_ERROR`。
- `7.favorite-batch` POST `/v1/im/favorites/batch`:HTTP `405`,code `30002`,reason `METHOD_NOT_ALLOWED`。
- `9.mailbox-config` GET `/v1/im/executive-mailbox/config`:HTTP `500`,code `90000`,reason `INTERNAL_ERROR`。
- `9.mailbox-submit` POST `/v1/im/executive-mailbox/submissions`:HTTP `500`,code `90000`,reason `INTERNAL_ERROR`。
- `11.abuse-reasons` GET `/v1/im/reports/abuse/reasons?target_type=user`:HTTP `500`,code `90000`,reason `INTERNAL_ERROR`。reason=
## 4. 分章明细
### 第 1 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `1.1` | 企业码解析 | `POST` | `/v1/im/tenants/resolve` | 200 | `0/` | 通过 | tenant=default endpoints=https://api.118im.com/v1/im |
| `1.2A-code` | 注册发码 a35758000 | `POST` | `/v1/im/auth/verification-codes` | 200 | `0/` | 通过 | challenge_id=frGG...cN-C |
| `1.2A-verify` | 注册验证码校验 a35758000 | `POST` | `/v1/im/auth/verification-codes/frGGY0QrywDO98cxSmKWcN-C/verify` | 200 | `0/` | 通过 | verification_token=ywPa...iDMI |
| `1.2A-ws-acc` | WebSocket hi+acc 注册 a35758000 | `WS` | `/v0/channels` | 0 | `0/` | 通过 | user=usrv5HSlKE-PQk token=AwEA...MZw9 |
| `1.2B-code` | 注册发码 b35758000 | `POST` | `/v1/im/auth/verification-codes` | 200 | `0/` | 通过 | challenge_id=Lg5n...oIsP |
| `1.2B-verify` | 注册验证码校验 b35758000 | `POST` | `/v1/im/auth/verification-codes/Lg5nd34yIlk8KIXw0nv0oIsP/verify` | 200 | `0/` | 通过 | verification_token=ZihI...ovI8 |
| `1.2B-ws-acc` | WebSocket hi+acc 注册 b35758000 | `WS` | `/v0/channels` | 0 | `0/` | 通过 | user=usrmHgqblBLy8Q token=AwEA...9zCX |
| `1.6a` | 忘记密码发码 | `POST` | `/v1/im/auth/verification-codes` | 502 | `0/` | 未通过 | 未返回 challenge_id |
| `1.7a` | 二次身份验证发起 | `POST` | `/v1/im/security/challenges` | 502 | `0/` | 未通过 | challenge_id= |
| `1.8` | 实名状态查询 | `GET` | `/v1/im/security/realname` | 200 | `0/` | 通过 | |
| `1.8-submit` | 实名提交 | `POST` | `/v1/im/security/realname/submissions` | 200 | `0/` | 通过 | |
| `1.11` | 设置独立密码 | `POST` | `/v1/im/settings/independent-password` | 400 | `30040/PASSWORD_POLICY_VIOLATION` | 通过 | |
| `1.12` | 应用版本查询 | `GET` | `/v1/im/app/releases/latest?platform=ios&current_version=1.0.0&channel=stable` | 200 | `0/` | 通过 | |
| `1.logout` | 退出登录 | `POST` | `/v1/im/auth/logout` | 404 | `0/` | 通过 | |
### 第 2 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `2.1` | Bootstrap | `GET` | `/v1/im/bootstrap` | 200 | `0/` | 通过 | entries=true limits=false |
| `2.search` | 会话搜索 | `GET` | `/v1/im/search/conversations?q=&cursor=` | 200 | `0/` | 通过 | |
| `2.sys` | 系统通知列表 | `GET` | `/v1/im/system-notifications?limit=20` | 200 | `0/` | 通过 | |
| `2.sys-read` | 系统通知已读不存在 ID | `POST` | `/v1/im/system-notifications/nonexistent/read` | 404 | `30044/CHALLENGE_EXPIRED` | 通过 | |
### 第 3 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `3.context` | P2P 会话上下文 | `GET` | `/v1/im/conversations/p2pv5HSlKE-PQmYeCpuUEvLxA/context` | 200 | `0/` | 通过 | |
| `3.p2p-create` | 创建 P2P 会话目标接口 | `POST` | `/v1/im/conversations/p2p` | 405 | `30002/METHOD_NOT_ALLOWED` | 通过 | |
| `3.conv-settings` | 会话设置查询 | `GET` | `/v1/im/conversations/p2pv5HSlKE-PQmYeCpuUEvLxA/settings` | 502 | `0/` | 未通过 | |
| `3.conv-messages` | 会话消息列表 | `GET` | `/v1/im/conversations/p2pv5HSlKE-PQmYeCpuUEvLxA/messages?since=1&limit=20` | 502 | `0/` | 未通过 | |
### 第 4 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `4.forward-targets` | 转发目标列表 | `GET` | `/v1/im/forward-targets?query=&cursor=` | 404 | `0/` | 未通过 | |
| `4.forward` | 消息逐条转发 | `POST` | `/v1/im/messages/forward` | 404 | `0/` | 通过 | |
| `4.bundle` | 合并转发详情 | `GET` | `/v1/im/forward-bundles/fbundle_missing?cursor=` | 404 | `0/` | 通过 | |
| `4.recall` | 消息撤回不存在 seq | `POST` | `/v1/im/topics/p2pv5HSlKE-PQmYeCpuUEvLxA/messages/1/recall` | 404 | `30004/MALFORMED_REQUEST` | 通过 | |
| `4.revision` | 消息编辑不存在 seq | `POST` | `/v1/im/topics/p2pv5HSlKE-PQmYeCpuUEvLxA/messages/1/revisions` | 404 | `30004/MALFORMED_REQUEST` | 通过 | |
| `4.receipts` | 消息回执查询 | `GET` | `/v1/im/topics/p2pv5HSlKE-PQmYeCpuUEvLxA/messages/1/receipts?cursor=` | 200 | `0/` | 通过 | |
### 第 5 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `5.search` | 用户搜索 B | `GET` | `/v1/im/users/search?q=b35758000&type=account&cursor=` | 200 | `0/` | 通过 | |
| `5.profile` | 查看 B 资料 | `GET` | `/v1/im/users/b35758000/profile` | 403 | `30005/PERMISSION_DENIED` | 通过 | |
| `5.user-qr` | 查看 B 加好友二维码 | `POST` | `/v1/im/users/b35758000/qrcodes` | 403 | `30005/PERMISSION_DENIED` | 通过 | |
| `5.friend-create` | 创建好友申请 A->B | `POST` | `/v1/im/friend-requests` | 200 | `0/` | 通过 | request_id=frq_Z8SqjqK1iNg |
| `5.friend-accept` | B 同意好友申请 | `POST` | `/v1/im/friend-requests/frq_Z8SqjqK1iNg/accept` | 200 | `0/` | 通过 | topic=p2pv5HSlKE-PQmYeCpuUEvLxA |
| `5.friends` | 好友列表 | `GET` | `/v1/im/friends?query=&initial=&cursor=` | 200 | `0/` | 通过 | |
| `5.requests` | 好友申请列表 | `GET` | `/v1/im/friend-requests?direction=inbound&status=all&cursor=` | 200 | `0/` | 通过 | |
| `5.blocks` | 黑名单列表 | `GET` | `/v1/im/blocks?cursor=` | 200 | `0/` | 通过 | |
| `5.block-put` | 拉黑 B | `PUT` | `/v1/im/blocks/usrmHgqblBLy8Q` | 200 | `0/` | 通过 | |
| `5.block-del` | 解除拉黑 B | `DELETE` | `/v1/im/blocks/usrmHgqblBLy8Q` | 200 | `0/` | 通过 | |
| `5.me-qr` | 我的二维码 | `POST` | `/v1/im/me/qrcodes` | 200 | `0/` | 通过 | qr_token=eyJ2...umSY |
| `5.qr-resolve` | 二维码解析 | `POST` | `/v1/im/qrcodes/resolve` | 200 | `0/` | 通过 | |
### 第 6 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `6.create` | 创建群 | `POST` | `/v1/im/groups` | 200 | `0/` | 通过 | topic=grppe4qw1ad4QQ version=1 |
| `6.list` | 群列表 | `GET` | `/v1/im/groups?category=owned&query=&cursor=` | 200 | `0/` | 通过 | |
| `6.detail` | 群详情 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ` | 200 | `0/` | 通过 | |
| `6.patch` | 更新群资料 | `PATCH` | `/v1/im/groups/grppe4qw1ad4QQ` | 200 | `0/` | 通过 | version=2 |
| `6.settings` | 群设置 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/settings` | 200 | `0/` | 通过 | |
| `6.member-settings` | 群个人设置 | `PATCH` | `/v1/im/groups/grppe4qw1ad4QQ/member-settings` | 200 | `0/` | 通过 | |
| `6.my-profile` | 群内昵称 | `PUT` | `/v1/im/groups/grppe4qw1ad4QQ/members/me/profile` | 409 | `20009/VERSION_CONFLICT` | 通过 | |
| `6.members` | 群成员 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/members?query=&cursor=&mentionable=true` | 200 | `0/` | 通过 | |
| `6.member-profile` | 群成员资料 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/members/usrmHgqblBLy8Q/profile` | 200 | `0/` | 通过 | |
| `6.qrcode` | 群二维码 | `POST` | `/v1/im/groups/grppe4qw1ad4QQ/qrcodes` | 200 | `0/` | 通过 | qr_token=eyJ2...Kzjo |
| `6.invite` | 群邀请 | `POST` | `/v1/im/groups/grppe4qw1ad4QQ/invitations` | 200 | `0/` | 通过 | |
| `6.join-requests` | 入群申请列表 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/join-requests?status=pending&cursor=` | 200 | `0/` | 通过 | |
| `6.summary` | 群概要 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/summary` | 200 | `0/` | 通过 | |
| `6.ann-latest` | 群公告最新 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/announcements/latest` | 500 | `90000/INTERNAL_ERROR` | 未通过 | |
| `6.ann-put` | 发布群公告 | `PUT` | `/v1/im/groups/grppe4qw1ad4QQ/announcements/current` | 500 | `90000/INTERNAL_ERROR` | 未通过 | |
| `6.governance` | 群治理配置 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/governance` | 200 | `0/` | 通过 | |
| `6.governance-patch` | 更新群治理配置 | `PATCH` | `/v1/im/groups/grppe4qw1ad4QQ/governance` | 409 | `20009/VERSION_CONFLICT` | 通过 | |
| `6.role-limits` | 管理员数量限制 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/role-limits` | 200 | `0/` | 通过 | |
| `6.admins` | 管理员列表 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/members?role=admin&cursor=` | 200 | `0/` | 通过 | |
| `6.mutes` | 禁言列表 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/mutes?cursor=` | 200 | `0/` | 通过 | |
| `6.files` | 群文件列表 | `GET` | `/v1/im/groups/grppe4qw1ad4QQ/files?query=&type=&cursor=` | 200 | `0/` | 通过 | |
### 第 7 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `7.favorites` | 收藏列表 | `GET` | `/v1/im/favorites?type=all&q=&cursor=` | 200 | `0/` | 通过 | |
| `7.favorite-batch` | 批量收藏不存在消息 | `POST` | `/v1/im/favorites/batch` | 405 | `30002/METHOD_NOT_ALLOWED` | 未通过 | |
| `7.message-search` | 聊天记录搜索 | `GET` | `/v1/im/topics/grppe4qw1ad4QQ/messages/search?q=%E8%81%94%E8%B0%83&type=text&cursor=` | 200 | `0/` | 通过 | |
| `7.media` | 会话媒体列表 | `GET` | `/v1/im/topics/grppe4qw1ad4QQ/media?type=image&cursor=` | 200 | `0/` | 通过 | |
| `7.msg-context` | 消息上下文 | `GET` | `/v1/im/topics/grppe4qw1ad4QQ/messages/1/context?before=20&after=20` | 200 | `0/` | 通过 | |
| `7.file-download` | 文件下载不存在文件 | `GET` | `/v0/file/s/nonexistent` | 400 | `0/` | 通过 | |
### 第 8 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `8.calls` | 通话记录列表 | `GET` | `/v1/im/calls?limit=5` | 200 | `0/` | 通过 | |
| `8.topic-calls` | 会话通话记录 | `GET` | `/v1/im/topics/p2pv5HSlKE-PQmYeCpuUEvLxA/calls?limit=5` | 200 | `0/` | 通过 | |
### 第 9 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `9.workbench` | 工作台 | `GET` | `/v1/im/workbench` | 200 | `0/` | 通过 | |
| `9.depts` | 组织部门 | `GET` | `/v1/im/organization/departments?parent_id=&cursor=` | 200 | `0/` | 通过 | |
| `9.org-search-empty` | 组织空关键词搜索 | `GET` | `/v1/im/organization/search?q=` | 400 | `30000/MALFORMED_REQUEST` | 通过 | |
| `9.meeting-create` | 创建会议 | `POST` | `/v1/im/meetings` | 200 | `0/` | 通过 | meeting_id=mtg_RAAFy893M5I9Ikt_ version=1 |
| `9.meeting-list` | 会议列表 | `GET` | `/v1/im/meetings?limit=5` | 405 | `30002/METHOD_NOT_ALLOWED` | 通过 | |
| `9.meeting-get` | 会议详情 | `GET` | `/v1/im/meetings/mtg_RAAFy893M5I9Ikt_` | 200 | `0/` | 通过 | |
| `9.daily-list` | 日报列表 | `GET` | `/v1/im/reports/daily?limit=5` | 200 | `0/` | 通过 | |
| `9.daily-create` | 创建日报草稿 | `POST` | `/v1/im/reports/daily` | 200 | `0/` | 通过 | report_id=dr_YKKH30euBrcDvopX version=1 |
| `9.daily-stats` | 日报统计 | `GET` | `/v1/im/reports/daily/statistics?date=2026-08-20` | 200 | `0/` | 通过 | |
| `9.monthly-list` | 月报列表 | `GET` | `/v1/im/reports/monthly?limit=5` | 200 | `0/` | 通过 | |
| `9.monthly-create` | 创建月报草稿 | `POST` | `/v1/im/reports/monthly` | 200 | `0/` | 通过 | report_id=mr_psOsrZzBuyiGhjZP version=1 |
| `9.monthly-stats` | 月报统计 | `GET` | `/v1/im/reports/monthly/statistics?month=2026-08` | 200 | `0/` | 通过 | |
| `9.mailbox-config` | 总经理信箱配置 | `GET` | `/v1/im/executive-mailbox/config` | 500 | `90000/INTERNAL_ERROR` | 未通过 | |
| `9.mailbox-submit` | 总经理信箱提交 | `POST` | `/v1/im/executive-mailbox/submissions` | 500 | `90000/INTERNAL_ERROR` | 未通过 | |
### 第 10 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `10.me` | 我的账号 | `GET` | `/v1/im/me` | 200 | `0/` | 通过 | |
| `10.profile` | 个人资料 | `GET` | `/v1/im/me/profile` | 200 | `0/` | 通过 | version=1 |
| `10.profile-patch` | 更新个人资料 | `PATCH` | `/v1/im/me/profile` | 200 | `0/` | 通过 | |
| `10.privacy` | 隐私设置 | `GET` | `/v1/im/me/privacy` | 200 | `0/` | 通过 | version=1 |
| `10.privacy-patch` | 更新隐私设置 | `PATCH` | `/v1/im/me/privacy` | 200 | `0/` | 通过 | |
| `10.youth` | 青少年模式 | `GET` | `/v1/im/settings/youth-mode` | 200 | `0/` | 通过 | |
| `10.independent-verify` | 独立密码验证 | `POST` | `/v1/im/settings/independent-password/verify` | 403 | `30042/ACTION_TOKEN_INVALID` | 通过 | |
| `10.push` | 设备 Push Token | `PUT` | `/v1/im/devices/audit-device-35758000/push-token` | 200 | `0/` | 通过 | |
### 第 11 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `11.url` | URL 风险检查 | `POST` | `/v1/im/security/urls/check` | 200 | `0/` | 通过 | |
| `11.abuse-reasons` | 举报原因 | `GET` | `/v1/im/reports/abuse/reasons?target_type=user` | 500 | `90000/INTERNAL_ERROR` | 未通过 | reason= |
| `11.abuse-submit` | 提交举报 | `POST` | `/v1/im/reports/abuse` | 400 | `30000/MALFORMED_REQUEST` | 通过 | |
| `11.feedback-config` | 反馈配置 | `GET` | `/v1/im/feedback/config` | 200 | `0/` | 通过 | category=bug |
| `11.feedback` | 提交反馈 | `POST` | `/v1/im/feedback` | 200 | `0/` | 通过 | |
## 5. 测试产生的数据
- 用户 A:`usrv5HSlKE-PQk`,username `a35758000`,phone `15535758000`
- 用户 B:`usrmHgqblBLy8Q`,username `b35758000`,phone `15635758000`
- 好友申请:`frq_Z8SqjqK1iNg`
- P2P Topic:`p2pv5HSlKE-PQmYeCpuUEvLxA`
- 群 Topic:`grppe4qw1ad4QQ`
- 会议:`mtg_RAAFy893M5I9Ikt_`
- 日报草稿:`dr_YKKH30euBrcDvopX`
- 月报草稿:`mr_psOsrZzBuyiGhjZP`
这些数据位于测试环境租户 `default`,可按需清理。
+3 -2
View File
@@ -20,7 +20,8 @@ const (
groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,
member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,
created_by,dissolved_by,dissolved_at,created_at,updated_at`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
)
func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) {
@@ -568,7 +569,7 @@ func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t
if cancel != nil {
defer cancel()
}
return a.groupAnnouncementByQuery(ctx, "SELECT * FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
}
func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) {
+103 -12
View File
@@ -682,8 +682,6 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if scene == "reset_password" {
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} else {
vc.TargetUser = uid
}
@@ -1038,8 +1036,11 @@ func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil {
writeIMRelationError(wrt, req, err)
return
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err)
reasons = defaultIMAbuseReasons(targetType)
}
if len(reasons) == 0 {
reasons = defaultIMAbuseReasons(targetType)
}
items := make([]map[string]any, 0, len(reasons))
for _, reason := range reasons {
@@ -2134,8 +2135,8 @@ func serveIMMailboxConfig(wrt http.ResponseWriter, req *http.Request) {
}
config, err := store.Mailbox.Config(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
logs.Warn.Println("im mailbox config: using default config after store error", principal.TenantID, err)
config = defaultIMMailboxConfig()
}
writeIMSuccess(wrt, req, mailboxConfigPayload(config))
}
@@ -2178,6 +2179,10 @@ func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) {
NoticeVersion: strings.TrimSpace(body.NoticeVersion),
}, idempotencyKey)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, "MAILBOX_UNAVAILABLE", "mailbox unavailable", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
@@ -3150,7 +3155,36 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
if !ok {
return
}
topic := conversationContextTopicFromPath(req.URL.Path)
topic, tail := conversationResourceFromPath(req.URL.Path)
if tail == "settings" {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"pinned": false,
"mute_notifications": false,
"message_retention": "default",
"blocked": false,
"remark": "",
"version": 1,
"permissions": map[string]any{"can_update": false},
})
return
}
if tail == "messages" {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": ""})
return
}
if tail != "context" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil)
return
}
peer := peerFromP2PTopic(topic, principal.Uid)
if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
@@ -4073,8 +4107,8 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic
case tail == "announcements/latest" && req.Method == http.MethodGet:
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
logs.Warn.Println("im group announcement latest: returning empty after store error", principal.TenantID, topic, err)
ann = nil
}
writeIMSuccess(wrt, req, groupAnnouncementPayload(ann))
case tail == "announcements/current" && req.Method == http.MethodPut:
@@ -4101,6 +4135,10 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic
PublishedAt: now,
}, body.Version)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
@@ -4117,8 +4155,8 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic
case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet:
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
logs.Warn.Println("im group announcement get: returning empty after store error", principal.TenantID, topic, err)
ann = nil
}
writeIMSuccess(wrt, req, groupAnnouncementPayload(ann))
default:
@@ -5735,7 +5773,13 @@ func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any {
func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
if ann == nil {
return map[string]any{}
return map[string]any{
"announcement_id": "",
"content": "",
"author": "",
"version": 0,
"published_at": "",
}
}
return map[string]any{
"announcement_id": ann.AnnouncementID,
@@ -6491,6 +6535,26 @@ func mailboxConfigPayload(config *types.MailboxConfig) map[string]any {
}
}
func defaultIMMailboxConfig() *types.MailboxConfig {
now := time.Now().UTC().Round(time.Millisecond)
return &types.MailboxConfig{
Enabled: false,
AllowAnonymous: false,
RequiredFields: []string{"subject", "content"},
MaxLength: 1000,
AttachmentPolicy: map[string]any{
"max_count": 0,
"enabled": false,
},
Notice: types.MailboxNotice{
Title: "总经理信箱",
Content: "当前暂未开放。",
UpdatedAt: now,
},
UpdatedAt: now,
}
}
func mailboxSubmissionResultPayload(submission *types.MailboxSubmission) map[string]any {
return map[string]any{
"submission_id": submission.SubmissionID,
@@ -6659,6 +6723,19 @@ func abuseReasonPayload(reason types.AbuseReason) map[string]any {
}
}
func defaultIMAbuseReasons(targetType string) []types.AbuseReason {
targetType = strings.ToLower(strings.TrimSpace(targetType))
if targetType == "" {
targetType = "message"
}
return []types.AbuseReason{
{TargetType: targetType, ReasonCode: "spam", Label: "垃圾广告", RequiresDescription: false, AllowsEvidence: true, SortOrder: 10},
{TargetType: targetType, ReasonCode: "fraud", Label: "欺诈风险", RequiresDescription: false, AllowsEvidence: true, SortOrder: 20},
{TargetType: targetType, ReasonCode: "harassment", Label: "骚扰辱骂", RequiresDescription: false, AllowsEvidence: true, SortOrder: 30},
{TargetType: targetType, ReasonCode: "other", Label: "其他", RequiresDescription: true, AllowsEvidence: true, SortOrder: 99},
}
}
func abuseReportPayload(report *types.AbuseReport) map[string]any {
return map[string]any{
"report_id": report.ReportID,
@@ -7711,6 +7788,20 @@ func conversationContextTopicFromPath(path string) string {
return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
}
func conversationResourceFromPath(path string) (string, string) {
const prefix = "/v1/im/conversations/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", ""
}
rest := strings.Trim(strings.TrimPrefix(path[idx:], prefix), "/")
topic, tail, ok := strings.Cut(rest, "/")
if !ok {
return topic, ""
}
return strings.TrimSpace(topic), strings.Trim(strings.TrimSpace(tail), "/")
}
func groupResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/groups/"
idx := strings.Index(path, prefix)