fix: stabilize test environment IM endpoints
This commit is contained in:
@@ -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¤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}, 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¤t_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`,可按需清理。
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user