调用接口
本页说明 Go SDK 调用 OpenAPI 的通用规则:REST 接口怎样对应到 SDK 的方法和参数,可选参数怎样表示,怎样翻页、读取批量接口的结果、判断错误,SDK 会自动重试哪些请求、被限流时怎样等待,以及文件上传、应用状态的影响和排查问题的方法。
各接口的参数和返回字段的含义以服务端 REST API 为准,本页不再重复;每个方法对应的接口见服务与方法。
命名规则
SDK 的方法与 REST 接口一一对应。客户端按模块分为 13 个服务:
| 服务 | 内容 | REST 文档 |
|---|---|---|
client.Users | 用户、登录凭证、登录设备、全局禁言 | 用户管理 |
client.Friends | 好友、好友申请、加好友方式 | 好友 |
client.Blacklist | 用户的黑名单 | 黑名单 |
client.Groups | 群、群成员、群黑名单、入群申请、用户的入群设置 | 群组管理 |
client.Messages | 发送、批量发送、查询、导出、撤回、编辑、置顶消息 | 发送消息 |
client.Conversations | 会话信息、会话消息、用户的会话状态和未读数 | 查询会话 |
client.Media | 上传、下载、查询和删除文件 | 文件 |
client.Push | 推送设置、会话免打扰、推送设备 | 推送 |
client.Chatrooms | 聊天室、成员、禁言、封禁、白名单、属性、消息 | 聊天室管理 |
client.Moderation | 内容安全的配置、词库、审核记录、违规、举报 | 内容安全 |
client.Calls | 音视频通话的查询、结束和统计 | 音视频 |
client.Channels | 独立 RTC 频道、票据、成员、统计与更正 | 频道 |
client.Presence | 在线状态 | 在线状态 |
方法和参数的对应方式:
- 路径参数是方法的位置参数,按路径中的顺序排列,在
ctx之后。例如PATCH /users/{username}为client.Users.Update(ctx, username, params),PUT /groups/{group_id}/members/{username}/role为client.Groups.SetRole(ctx, groupID, username, role)。 - 查询参数和请求体字段在参数结构体中,类型名为“动作 + 对象 +
Params”,如CreateUserParams、SendMessageParams、ListUsersParams。只有一两个参数的接口直接作为位置参数,如client.Groups.Delete(ctx, groupID, reason)。 - 字段名由 JSON 字段名转为 Go 的写法:
snake_case转为CamelCase,缩写全大写,如client_msg_id为ClientMsgID,avatar_url为AvatarURL,to_user为ToUser。 - 结果是对应的类型,字段带 JSON 标签,如
*drim.User、*drim.SendResult。没有响应体的接口(204 No Content)只返回error。 - 每个结果类型都有
Raw字段:服务端返回的原始 JSON。服务端以后新增的字段,不必等 SDK 升级就能从中读取;不认识的字段和枚举值不会报错。 - 自由内容是
json.RawMessage:消息的Body、Ext等原样发送和返回,写法逐字节保留(发送时只去掉空白)。用drim.JSON(s)把 JSON 文本转为它,或用drim.Marshal(v)编码 Go 的值(不把<、>、&转义)。 - 时间是
time.Time:请求中按 RFC 3339 发送,响应解析为time.Time。 - 用户名不区分大小写:服务端把用户名转为小写,响应中的用户名都是小写。SDK 不改动你传入的用户名;用响应中的用户名作为键(如批量结果的失败项)时,按小写比较。
可选参数与零值
参数结构体中,零值的字段不出现在请求中(不传)。有三种写法:
| 字段类型 | 含义 | 写法 |
|---|---|---|
普通类型(string、bool、int 等) | 零值即“不传”,零值本身没有特别含义 | Nickname: "张三" |
指针(*string、*int64、*bool 等) | nil 为“不传”,零值有意义(如空字符串表示清空、false 表示关闭) | drim.String("")、drim.Int64(3600)、drim.Bool(false) |
drim.Field[T] | 区分“不传”、“传 null”和“传值”三种状态 | 零值不传,drim.Null[T]() 传 null,drim.Set(v) 传值 |
drim.String、drim.Int、drim.Int64、drim.Bool、drim.Float64 返回值的指针。
Field 用于 null 另有含义的字段,如修改用户资料时 attributes 为 null 清空全部自定义属性、某一项为 null 删除这一项:
// 修改昵称,设置 vip 属性、删除 level 属性,其他属性保持不变
user, err := client.Users.Update(ctx, "zhangsan", drim.UpdateUserParams{
Nickname: drim.String("张三"),
Attributes: drim.Set(map[string]drim.Field[string]{
"vip": drim.Set("1"),
"level": drim.Null[string](),
}),
})
if err != nil {
return err
}
fmt.Println(user.Nickname, user.Version)
// 清空全部自定义属性
_, err = client.Users.Update(ctx, "zhangsan", drim.UpdateUserParams{
Attributes: drim.Null[map[string]drim.Field[string]](),
})
return err并发修改
可以修改的对象带版本号(用户的 Version、群的 InfoVersion 等),修改类参数中有可选的 Version:给出时只有版本一致才修改,否则返回 version_conflict;不给时直接覆盖。见并发修改与 version。SDK 不自动重试 version_conflict,也不自动读取最新版本。
本地检查
SDK 在发出请求之前检查一部分参数,不合规的不发送,返回错误码为 invalid_params 的 *drim.Error,Details["field"] 指出是哪个参数:
- 缺少必填的参数(如发消息时
ToUser和ToGroup必须恰好给一个、Type和Body必填); - 路径参数为空字符串、
.或..; - 批量接口的项数超过上限(如每次最多 100 个用户名);
ClientMsgID、WithRequestID的格式不对;- 请求体超过 1 MiB(错误码为
payload_too_large,StatusCode为 0)。
与应用设置有关的规则(如消息大小上限、人数上限)由服务端检查。
分页
列表接口有两个方法:逐页的方法返回一页(如 List),逐项遍历的方法自动翻页(如 All),返回 Go 1.23 的迭代器 iter.Seq2[T, error]。
自动翻页
// 遍历全部正常状态的用户
for user, err := range client.Users.All(ctx, drim.ListUsersParams{Status: "active"}) {
if err != nil {
return err // 某一页最终失败:之前产出的项有效
}
fmt.Println(user.Username, user.Nickname)
}
return nil- 每一页的请求照常经过重试和限流等待;某一页最终失败时,迭代器产出这个错误后结束。
- 是否结束只看服务端给出的下一页标记,不看条数:一页的条数可能少于
Limit甚至为 0,而后面还有数据。 break跳出循环即停止,不再请求下一页。- 服务端返回的下一页游标与这一页相同时,迭代器产出
invalid_response错误后结束,避免死循环。
逐页读取
需要自己控制翻页(如分批处理、中断后从上次的位置继续)时,用逐页的方法。游标分页的参数嵌入了 drim.ListOptions(Cursor、Limit),结果是 *drim.Page[T]:
// 从上次保存的游标继续,每页 100 个
cursor := loadCursor()
for {
page, err := client.Users.List(ctx, drim.ListUsersParams{
Status: "active",
ListOptions: drim.ListOptions{Cursor: cursor, Limit: 100},
})
if err != nil {
return err
}
for _, user := range page.Items {
fmt.Println(user.Username)
}
if page.NextCursor == "" {
return nil // 没有下一页
}
cursor = page.NextCursor
saveCursor(cursor)
}Limit为 0 时用服务端的默认值(大多数列表默认 20、最多 100),见分页。NextCursor为空表示没有下一页。游标原样传回,不要解析;游标只在同样的筛选条件下有效,改变条件要从第一页开始。
其他翻页方式
| 方式 | 方法 | 结果类型 | 说明 |
|---|---|---|---|
| 游标 | 大多数列表,如 Users.List、Groups.List | *Page[T] | 遍历方法为 All、AllMembers 等,见服务与方法 |
| 带版本号的列表 | Friends.List、Blacklist.List、Groups.Members、Push.Mutes | *VersionedPage[T] | 参数中的 KnownVersion 只在第一页有效:与当前版本相同时 NotModified 为 true,Items 为空。另有 Version、Count(好友数、黑名单人数、群成员数)、MaxCount(好友和黑名单的上限)。遍历方法不使用 KnownVersion |
| 按序号 | Conversations.Messages | *SeqPage | HasMore、NextAfterSeq、NextBeforeSeq;按 AroundSeq 取时另有 HasMoreBefore、HasMoreAfter。遍历方法为 Conversations.AllMessages(不支持 AroundSeq) |
| 按消息 ID 导出 | Messages.Export | *ExportPage | HasMore、NextAfterMessageID。遍历方法为 Messages.ExportAll |
| 循环删除 | Friends.DeleteAll | 删除的总数 | SDK 循环调用到 has_more 为 false,出错时返回已删除的数和错误,再次调用即可继续 |
| 不分页 | 如 Users.Sessions、Push.Devices、Moderation.WordLists、Chatrooms.RecentMessages | 切片 | 没有遍历方法 |
只在数据有变化时才读取好友列表:
// 第一页带上本地保存的版本号;没有变化时只返回版本号
page, err := client.Friends.List(ctx, "zhangsan", drim.ListFriendsParams{KnownVersion: drim.Int64(localVersion)})
if err != nil {
return err
}
if page.NotModified {
return nil
}
var friends []drim.Friend
for friend, err := range client.Friends.All(ctx, "zhangsan", drim.ListFriendsParams{}) {
if err != nil {
return err
}
friends = append(friends, friend)
}
saveFriends(page.Version, friends)
return nil遍历期间数据有变化时,SDK 不重新开始;需要时以新的版本号再取一次。
导出应用的全部消息(按消息 ID 从旧到新,只导出 30 秒之前发送的),中断后从最后一条继续;遍历结束后保存 lastMessageID,下次从它继续即为增量导出:
for msg, err := range client.Messages.ExportAll(ctx, drim.ExportParams{AfterMessageID: lastMessageID, Limit: 1000}) {
if err != nil {
return err // 下次从 lastMessageID 之后继续
}
if err := archive(msg); err != nil {
return err
}
lastMessageID = msg.MessageID
}
return nilConversations.AllMessages 给出 AfterSeq(或只给 StartTime)时向新的方向遍历(序号从小到大),否则从 BeforeSeq(不给为最新)向旧的方向遍历(序号从大到小)。
批量接口的结果
批量接口整体返回 200,逐项给出结果,见批量接口。参数不合规、额度不足等整体的错误仍以 error 返回;单项的失败在结果中,类型为 drim.ItemError(不是 *drim.Error):
| 字段 | 说明 |
|---|---|
Key | 项的标识:用户名(小写)、消息 ID、记录 ID、属性的键等 |
Code | 错误码,取值与 *drim.Error 的相同 |
Message | 说明,只用于日志;有的接口没有 |
Details | 附加信息,可能为 nil;Reason() 返回 details.reason |
每个批量接口有自己的结果类型,都有三个方法:FailedItems() 返回失败的项,OK() 表示是否全部成功,Err() 在有失败时返回汇总的错误(*drim.BatchError,其中 Failed 为失败的项),否则返回 nil。
| 方法 | 结果类型 | 成功的项 | 失败的项 |
|---|---|---|---|
Users.BatchCreate | BatchCreateUsersResult | Created | Failed(没有 Details) |
Friends.BatchAdd | BatchAddFriendsResult | Added(含已是好友的) | Failed |
Friends.BatchDelete | BatchDeleteFriendsResult | Deleted | Failed |
Blacklist.BatchAdd、BatchRemove | BatchBlacklistResult | Added 或 Removed | Failed |
Groups.Create、AddMembers、RemoveMembers | CreateGroupResult、GroupMembersResult | Results 中 Error 为 nil 的,Status 为 joined、already_member、removed、not_member 等 | Results 中 Error 不为 nil 的 |
Messages.BatchSend | BatchSendResult | Results 中 Status 为 sent、duplicate 的 | Results 中 Error 不为 nil 的 |
Messages.BatchRecall | BatchRecallResult | Results 中 Error 为 nil 的 | 同左,Error 不为 nil 的 |
Chatrooms.Kick、Mute、Unmute、Ban、Unban、AddToAllowlist、RemoveFromAllowlist | RoomBatchResult | Results 中 Error 为 nil 的,Result 为结果 | Results 中 Error 不为 nil 的 |
Chatrooms.SetAttributes、RemoveAttributes | RoomAttributesChange | 不在 Failed 中的键 | Failed(键为属性名) |
Moderation.AddWords | AddWordsResult | Added(个数)、Duplicated | Invalid(Key 为词条) |
Moderation.BatchDecide | BatchDecideResult | Results 中 Item 不为 nil 的 | Results 中 Error 不为 nil 的 |
Media.DownloadURLs | []DownloadURLItem | DownloadURL 不为空的 | Error 不为 nil 的 |
result, err := client.Groups.AddMembers(ctx, groupID, []string{"lisi", "wangwu", "zhaoliu"})
if err != nil {
return err // 整个请求失败,没有任何一项执行
}
for _, item := range result.FailedItems() {
if item.Code == drim.CodePermissionDenied && item.Reason() == "group_blacklisted" {
log.Printf("%s 在群黑名单中,没有加入", item.Key)
continue
}
log.Printf("%s 加入失败:%s", item.Key, item.Code)
}
return nil自动拆分
Users.BatchCreateAll、Friends.BatchAddAll、Messages.BatchSendAll 按接口的上限拆分、依次提交、合并结果,用于导入用户、群发等场景:
- 服务端以
too_many_items给出更小的上限时(如应用的max_recipients_per_message),按它重新拆分。 BatchSendAll的每一批用同一个ClientMsgID,服务端按接收人去重,重复提交不会重复发送。- 某一批整体失败时停止,返回已完成部分的合并结果和这一批的错误。没有出现在结果中的项可以再提交一次。
- 合并后的
Raw为{"batches":[各批的原始 JSON]}。 - 每一批按项数计入 OpenAPI 额度,与本地限速配合使用,把额度留给线上请求。
// 从旧系统迁移用户:每批 100 个,本地限速每秒 200 项
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
RateLimit: &drim.RateLimit{PerSecond: 200},
})
if err != nil {
return err
}
defer client.Close()
var users []drim.CreateUserParams
for i := 0; i < 1000; i++ {
users = append(users, drim.CreateUserParams{Username: fmt.Sprintf("legacy-%04d", i), Nickname: fmt.Sprintf("用户 %d", i)})
}
result, err := client.Users.BatchCreateAll(ctx, users)
if result != nil {
log.Printf("创建了 %d 个", len(result.Created))
for _, item := range result.FailedItems() {
if item.Code != drim.CodeAlreadyExists { // 已存在的视为上次已导入
log.Printf("%s 失败:%s", item.Key, item.Code)
}
}
}
return err // 不为 nil 时,某一批整体失败,之后的没有提交错误类型与判断
SDK 返回的错误都是 *drim.Error(批量接口的单项失败和回调处理的错误除外),用 errors.As 取得:
| 字段 | 说明 |
|---|---|
StatusCode | HTTP 状态码;SDK 自己的错误为 0(invalid_response 为实际的状态码) |
Code | 服务端的错误码,或 SDK 自己的错误码 |
Message | 服务端的中文说明,只用于日志,不要用它判断 |
Details | details 原样,没有时为 nil;Reason() 返回 details.reason |
RequestID | 这次请求的 ID,排查问题时提供它 |
RetryAfter | 响应头 Retry-After |
Op | 接口名,如 users.create;换取令牌失败时为 app.token |
Attempts | 这次调用一共尝试了几次 |
Retried | 这个错误来自自动重试,而之前的尝试结果不明,见重试与幂等 |
Err | 底层错误(网络错误、context.Canceled、context.DeadlineExceeded 等),Unwrap 返回它 |
err.Error() 形如 users.create: 409 already_exists: 用户名已被占用 (request_id=...),不含请求体和令牌,可以直接写入日志。
服务端的错误码
服务端的错误码和 details 的取值见错误码,SDK 为每个错误码定义了常量,如 drim.CodeNotFound、drim.CodeRateLimited;服务端以后新增的错误码照常放在 Code 中。按 Code 判断,需要区分原因时再看 Reason() 和 Details:
_, err := client.Groups.AddToBlacklist(ctx, groupID, "lisi", "广告")
var e *drim.Error
switch {
case err == nil:
return nil
case drim.IsCode(err, drim.CodeNotFound):
return fmt.Errorf("群不存在或已解散")
case drim.IsReason(err, drim.CodeLimitExceeded, "group_blacklist_limit"):
return fmt.Errorf("群黑名单已满")
case drim.IsCode(err, drim.CodePermissionDenied):
return fmt.Errorf("不能把群主加入群黑名单")
case errors.As(err, &e) && e.Code == drim.CodeInvalidArgument:
log.Printf("参数错误:%v,字段 %v", e.Reason(), e.Details["field"])
return err
default:
return err
}判断函数:
| 函数 | 为真的情况 |
|---|---|
drim.IsCode(err, code) | err 是错误码为 code 的 *drim.Error |
drim.IsReason(err, code, reason) | 错误码为 code 且 details.reason 为 reason |
drim.IsTemporary(err) | 网络错误、超时、internal、5xx、rate_limited:稍后可以再试 |
drim.IsServiceUnavailable(err) | 服务暂停或只读:tenant_unavailable、app_unavailable,以及因此进入冷却期的 client_unavailable |
drim.IsPermanentlyUnavailable(err) | 应用已删除、租户已注销(unauthenticated 的 app_deleted、tenant_closed),以及因此永久失效的客户端返回的 client_unavailable:应清除保存的凭据 |
SDK 自己的错误码
| 错误码 | 常量 | 含义 |
|---|---|---|
network_error | CodeNetworkError | 连接失败、连接中断、TLS 错误(如证书校验失败);Err 为底层错误 |
timeout | CodeTimeout | 某次尝试超时,或 ctx 的截止时间已到;errors.Is(err, context.DeadlineExceeded) 为真 |
canceled | CodeCanceled | ctx 被取消;errors.Is(err, context.Canceled) 为真 |
invalid_params | CodeInvalidParams | 本地检查不通过,Details["field"] 指出参数 |
payload_too_large | CodePayloadTooLarge | 请求体超过 1 MiB,没有发送(与服务端的错误码同名,StatusCode 为 0) |
invalid_response | CodeInvalidResponse | 响应不是预期的 JSON(如网关返回的 HTML 页面),Details["body_prefix"] 为响应体的前 512 字节 |
client_unavailable | CodeClientUnavailable | 客户端已关闭、已永久失效,或处于换取令牌失败后的冷却期;Details["cause"] 为原因(closed 或最初的错误码) |
重试与幂等
SDK 为每个接口标明能否安全地重试,按它决定网络错误、超时、5xx 时是否自动重试(每个方法的类别见服务与方法):
| 类别 | 接口 | 网络错误、超时、5xx |
|---|---|---|
| 查询 | 所有 GET,以及查询性质的 POST(按用户名批量查询、检查好友关系、换取下载地址、查询在线状态、检查文本等) | 自动重试 |
| 带去重 ID 的写操作 | 发送消息、批量发送单聊、发送聊天室消息(client_msg_id) | 用同一个去重 ID 自动重试 |
| 以唯一标识创建 | 创建用户、批量创建用户(用户名) | 自动重试 |
| 本身幂等的写操作 | 重复调用结果相同或无害的:修改、删除、封禁、拉黑、加人和移人、禁言、撤回、置顶、标记已读、完成上传、签发登录凭证、代用户举报、不带处置或带 Version 的审核结论等 | 自动重试 |
| 不幂等的写操作 | 建群、创建聊天室、创建上传、创建词库、转交平台、设置密码、踢掉全部设备、只推在线的消息、低优先级的聊天室消息、带处置而不带 Version 的审核结论 | 请求可能已经发出的不重试,直接返回错误 |
- 重试按 0.5、1、2 秒(各乘以 0.5 到 1.5 的随机数)退避,默认最多重试 3 次;服务端给了
Retry-After的不短于它。次数用Config.Retry或drim.WithMaxAttempts修改。 - 连接没有建立成功(DNS 解析失败、连接被拒绝)的请求一定没有发出,所有类别都会重试。证书校验失败不重试。
- 被限流的请求没有执行,所有类别都会在等待后重发,见限流。
- App Token 失效(
unauthenticated)时换取新令牌后重发一次,见App Token 的获取与缓存。
重试之后的结果
前一次尝试的结果不明(如连接中断)而重试的,前一次可能已经执行了。重试后得到以下错误时,错误的 Retried 为 true,可以按“前一次已经执行”处理:
- 创建用户得到
already_exists:用户是前一次尝试创建的; - 删除得到
not_found:前一次已经删除; - 带
Version的修改得到version_conflict:前一次已经修改,重新读取看到的是修改后的数据。
_, err := client.Users.Create(ctx, drim.CreateUserParams{Username: "zhangsan", Nickname: "张三"})
var e *drim.Error
if errors.As(err, &e) && e.Code == drim.CodeAlreadyExists && e.Retried {
err = nil // 前一次尝试已经创建
}
return err发送消息的去重 ID
发送消息、发送聊天室消息时,SDK 在你没有给出 ClientMsgID 时自动生成一个(32 个十六进制字符),使自动重试不会重复发送。建议用业务单号作为 ClientMsgID(1 到 64 个可见 ASCII 字符,不能以 evt_、sys_ 开头),这样业务层自己的重试也不会重复发送:
- 同一会话中重复的
ClientMsgID不再发送,结果的Duplicate为true,返回的是第一次发送的消息; - 批量发送单聊的
ClientMsgID必填,服务端按接收人去重,结果中重复的项Status为duplicate; - 只推在线的消息(
OnlineOnly)和低优先级的聊天室消息不去重,SDK 不生成去重 ID,也不自动重试。
代用户举报的结果同样有 Duplicate(同一举报人对同一对象只记一次)。
不幂等的操作超时以后
不幂等的写操作超时或连接中断后,先查询结果再决定是否重试:
- 建群:按群主查询用户加入的群(建群时把业务 ID 写进群的
Attributes,便于确认); - 创建聊天室:按名称或所有者查询聊天室列表;
- 创建词库:用
Moderation.WordLists按名称查找; - 签发登录凭证可以直接再签发一个,只把最后拿到的交给 App。
限流
OpenAPI 按应用限流,超出时返回 429 rate_limited 和 Retry-After,见限流与配额。SDK 收到 rate_limited 时按 Retry-After 等待(加 0 到 20% 的随机量)后重发,默认最多 3 次。等待的范围按 details.reason 决定:
details.reason | 限制 | 一起等待的请求 |
|---|---|---|
| 没有 | 应用的 OpenAPI 额度 | 整个客户端:之后发出的请求都先等到同一时刻,而不是各自重试 |
app_message_rate | 应用每分钟的消息数 | 这个客户端中发送消息的请求(Messages.Send、BatchSend);等待时长在 Retry-After 到它的 2 倍之间随机 |
request_rate | 代用户举报的频率 | 这个客户端中代用户举报的请求 |
其他,如 group_send_rate、room_send_rate、image_busy | 某个群、聊天室、文件的限制 | 只有这个请求 |
Retry-After超过MaxRetryAfter(默认 60 秒)的不等待,直接返回rate_limited:通常是额度已用完,应由你的任务队列决定何时再试。- 等待算在
ctx的时限内:等待会超过截止时间时不再等待,直接返回rate_limited。 - 等待重发
MaxRateLimited次(默认 3 次)后仍被限流的,返回rate_limited。错误的RetryAfter为服务端给出的等待时间。 - 不想在线上请求中等待的,用
drim.WithMaxRetryAfter(ctx, 0),被限流时立即返回。
本地限速
批量导入、群发等后台任务可以在本地限速,把应用的额度留给线上请求:
// 后台任务专用的客户端:每秒最多 200 次,批量接口按项数计
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
RateLimit: &drim.RateLimit{PerSecond: 200, Burst: 200},
})
if err != nil {
return err
}
defer client.Close()- 本地限速是令牌桶:
PerSecond为每秒的请求数,Burst为桶的容量(默认为PerSecond向上取整,至少 1)。 - 批量接口按项数计(与服务端的额度计算一致),一个请求最多扣到桶的容量。
- 本地限速的等待也算在
ctx的时限内,会超过截止时间时返回timeout。 - 线上请求和后台任务分别创建客户端,只给后台任务的客户端设置
RateLimit。
文件上传与下载
上传文件要经过创建上传、上传内容、完成上传三步(见上传文件)。client.Media.Upload 一次完成:
f, err := os.Open("photo.jpg")
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
file, err := client.Media.Upload(ctx, f, st.Size(), drim.UploadParams{Purpose: "attachment", Kind: "image"})
if err != nil {
return err
}
// 把文件地址(不是下载地址)写入图片消息
body, err := drim.Marshal(map[string]any{"url": file.URL, "size": file.Size, "width": file.Width, "height": file.Height})
if err != nil {
return err
}
_, err = client.Messages.Send(ctx, drim.SendMessageParams{From: "notice", ToUser: "zhangsan", Type: "image", Body: body})
return err- 内容的来源是
io.ReaderAt(*os.File、*bytes.Reader、*strings.Reader都实现了它),用于分片的并发读取和重传;只有io.Reader的(如网络流),先写入临时文件。size为内容的字节数。 - 不超过 32 MB 的一次上传;更大的分片上传,每片 16 MB,默认同时上传 4 片(
Concurrency)。 - 上传内容的请求直接发到对象存储,不带
Authorization;失败时每片单独重试 3 次,地址过期时重新索取。 - 完成上传时服务端核对内容、生成缩略图,最长约 2 分钟;SDK 按服务端的结果等待、重试或补传缺少的分片,最终返回文件信息。
ctx被取消或到期时停止上传,并尽力取消这次上传(独立的 5 秒时限,失败不报错)。
UploadParams 的字段:
| 字段 | 说明 |
|---|---|
Purpose | 用途:attachment(消息附件)、group_file(群文件)、user_avatar、group_avatar、chatroom_avatar |
Kind | 类型:image、voice、video、file;头像只能是 image,群文件不能是 voice |
Name | 文件名,类型为 file 和群文件必填;最长 255 个字符,不能含 /、\ 和控制字符 |
ContentType | 只作提示,文件的格式由服务端按内容识别 |
GroupID | 群文件所在的群,群文件必填 |
Owner | 所属用户,用户头像必填 |
Concurrency | 分片上传的并发数,默认 4 |
OnProgress | 每个分片(一次上传为整个文件)完成后调用,参数为已上传和总的字节数;不能阻塞 |
SDK 在本地检查用途与类型的组合、头像不超过 5 MB、文件名的规则,以及群文件的 GroupID 和用户头像的 Owner,不合规的返回 invalid_params。与应用设置有关的上限由服务端检查。上传到对象存储失败的错误 Op 为 media.upload 或 media.upload_part。
为用户设置头像
f, err := os.Open("avatar.png")
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
file, err := client.Media.Upload(ctx, f, st.Size(), drim.UploadParams{Purpose: "user_avatar", Kind: "image", Owner: "zhangsan"})
if err != nil {
return err
}
_, err = client.Users.Update(ctx, "zhangsan", drim.UpdateUserParams{AvatarURL: drim.String(file.URL)})
return err续传
分片上传(超过 32 MB 的文件)在 24 小时内(从创建上传时算起)中断的,可以用文件 ID 和同一份内容续传:ResumeUpload 查询已上传的分片,补传缺少的和大小不对的,再完成上传。续传时 ctx 被取消只停止,不取消上传,之后还可以再续传。
Upload 失败时不返回文件 ID(ctx 被取消时还会取消这次上传),所以需要续传的大文件,先用 CreateUpload 创建上传并保存文件 ID,再用 ResumeUpload 上传内容:第一次调用时没有已上传的分片,全部上传;中断后用同一个文件 ID 再调用即可。
// 第一步:创建上传,把文件 ID 保存到你的数据库
func startUpload(ctx context.Context, client *drim.Client, size int64) (string, error) {
session, err := client.Media.CreateUpload(ctx, drim.CreateUploadParams{Purpose: "attachment", Kind: "video", Size: size})
if err != nil {
return "", err
}
return session.FileID, nil
}
// 第二步:上传内容并完成;中断后用同一个文件 ID 再调用
func uploadContent(ctx context.Context, client *drim.Client, fileID, path string) (*drim.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
return client.Media.ResumeUpload(ctx, fileID, f, st.Size(), drim.UploadParams{
OnProgress: func(uploaded, total int64) { log.Printf("已上传 %d / %d", uploaded, total) },
})
}下载
out, err := os.Create("download.jpg")
if err != nil {
return err
}
defer out.Close()
n, err := client.Media.Download(ctx, fileURL, out)
if err != nil {
return err
}
log.Printf("下载了 %d 字节", n)
return nilDownload 先换取下载地址,再直接请求它(不带 Authorization,不经过 OpenAPI 的重试和限流),把内容写入 io.Writer;下载地址过期时重新换取一次。缩略图的地址(文件地址加 /thumb)同样可以下载。下载地址只有 60 到 90 分钟有效,需要下载地址本身时用 client.Media.DownloadURLs,不要保存或写进消息。
处理待审核的内容
client.Moderation.ProcessPending 遍历租户审核队列中待处理的记录(pending、auto_violation),读取内容后交给你的函数,按返回的结论处理:
decided, err := client.Moderation.ProcessPending(ctx, drim.ProcessOptions{Scene: "message"},
func(ctx context.Context, item drim.ModerationItem, content drim.ItemContent) (*drim.Decision, error) {
if needHumanReview(item) {
return nil, nil // 返回 nil 跳过这条
}
return &drim.Decision{Decision: "no_violation", Note: "自动复核通过"}, nil
})
log.Printf("处理了 %d 条", decided)
return err- 作出结论时带上读到的
Version,所以可以安全重试;记录已被别人处理(version_conflict)的跳过。 - 你的函数返回错误或作出结论失败时,默认记警告日志后继续下一条;
StopOnError为true时停止并返回错误。 - 返回作出结论的条数;取下一页失败时返回已作出的条数和这个错误。
参数和结论的取值见审核记录。
应用和租户的状态
应用或租户的状态变化时(见应用和租户的状态),SDK 的表现:
| 状态 | 调用 OpenAPI 得到的结果 | 判断 |
|---|---|---|
| 只读(平台设置或欠费) | 查询和处置类接口正常;新增数据、修改资料返回 app_unavailable | drim.IsServiceUnavailable(err) |
| 应用停用、租户暂停、注销冷静期 | 已有的令牌立即失效;SDK 重新换取时得到 app_unavailable 或 tenant_unavailable,之后 2 到 5 分钟内直接返回 client_unavailable,不再换取 | drim.IsServiceUnavailable(err) |
| 应用删除、租户注销 | unauthenticated(app_deleted、tenant_closed),客户端永久失效 | drim.IsPermanentlyUnavailable(err) |
状态恢复后,SDK 在冷却期结束后的下一次请求自动换取新令牌,不需要重新创建客户端。暂停期间发生的事件不回调、恢复后也不补发,需要的数据用 OpenAPI 查询或导出补齐。
日志与排查
日志
SDK 默认不输出日志。把你的 *slog.Logger 交给 Config.Logger:
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
Logger: logger,
})
if err != nil {
return err
}
defer client.Close()| 级别 | 内容 |
|---|---|
debug | 业务结果类的失败(如 not_found、already_exists)、限流等待、从令牌存储取得令牌 |
info | 换取到新令牌(含有效期,不含令牌)、更新凭据 |
warn | 请求失败后将要重试、限流等待超过 5 秒、令牌存储出错 |
error | 最终失败的网络错误、超时、5xx、unauthenticated、ip_not_allowed、method_not_allowed、invalid_response、client_unavailable,换取令牌失败、客户端永久失效 |
日志带 op(接口名)、status、code、reason、request_id、attempts 等字段,不记录 Client Secret、App Token、登录凭证、密码、消息内容和上传、下载地址。其他 4xx 是你的业务结果,SDK 只记 debug,由你决定是否记录。
请求 ID
每个请求都带 X-Request-ID。没有用 drim.WithRequestID 指定时由 SDK 生成,同一次调用的重试沿用同一个。出错时 e.RequestID 为服务端返回的请求 ID,联系技术支持时提供它。
钩子与统计
Config.Hooks 在每次尝试的前后调用,用于注入追踪头、记录指标(换取令牌的请求同样调用,接口名为 app.token):
requests := map[string]int{} // 示例:按接口名和结果计数(实际请换成你的指标库)
var mu sync.Mutex
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
Hooks: drim.Hooks{
BeforeRequest: func(ctx context.Context, req *http.Request) {
req.Header.Set("traceparent", traceparentFrom(ctx)) // 注入追踪头
},
AfterResponse: func(ctx context.Context, info drim.RequestInfo) {
mu.Lock()
requests[info.Op+" "+info.Code]++
mu.Unlock()
if info.Duration > time.Second {
log.Printf("慢请求 %s %s 第 %d 次尝试 %v request_id=%s", info.Method, info.URL, info.Attempt, info.Duration, info.RequestID)
}
},
},
})
if err != nil {
return err
}
defer client.Close()- 钩子在发出请求的协程中同步调用,不能阻塞太久;钩子 panic 时 SDK 不恢复。
BeforeRequest看到的请求中有Authorization和请求体,不要记录它们。RequestInfo含Op、Method、URL(不含查询参数)、Attempt、StatusCode(没有收到响应时为 0)、Code(成功时为空)、Reason、RequestID、Duration和RateLimitWait(这次尝试之前的限流等待)。
client.Stats() 返回按接口名统计的快照:Requests(尝试的次数,含重试)、Failures(最终失败的调用数)、Retries、RateLimitWait(限流等待的总时长),可以定期读取后上报:
for op, s := range client.Stats().Ops {
log.Printf("%s 请求 %d 次,失败 %d 次,重试 %d 次,限流等待 %v", op, s.Requests, s.Failures, s.Retries, s.RateLimitWait)
}用测试服务模拟各种结果
drimtest.NewServer() 启动一个假的 OpenAPI 服务,可以按接口名设置返回值或错误,并记录收到的请求,用来测试你的代码在限流、出错时的行为:
package notify_test
import (
"context"
"testing"
drim "github.com/deeprespond/im/sdk/server/go"
"github.com/deeprespond/im/sdk/server/go/drimtest"
)
func TestSendRetriesAfterRateLimit(t *testing.T) {
srv := drimtest.NewServer()
defer srv.Close()
// 后设置的规则先用:第一次发送被限流(只生效一次),SDK 等待 1 秒后重发,得到 201
srv.Respond("messages.send", 201, `{"message_id":"1840000000000000001","seq":7}`)
srv.Fail("messages.send", drim.CodeRateLimited, drimtest.FailOptions{RetryAfter: "1"})
client, err := drim.New(srv.Config())
if err != nil {
t.Fatal(err)
}
defer client.Close()
result, err := client.Messages.Send(context.Background(), drim.SendMessageParams{
From: "notice", ToUser: "zhangsan", ClientMsgID: "order-1001-paid", Type: "text", Body: drim.JSON(`{"text":"hi"}`),
})
if err != nil {
t.Fatal(err)
}
if result.Seq != 7 || result.Duplicate {
t.Fatalf("unexpected result: %+v", result)
}
calls := srv.Calls("messages.send")
if len(calls) != 2 || calls[1].JSON()["client_msg_id"] != "order-1001-paid" {
t.Fatalf("expected 2 sends with the same client_msg_id, got %d", len(calls))
}
}| 方法 | 说明 |
|---|---|
srv.Config() | 连到这个服务的 drim.Config(不使用代理) |
srv.Respond(op, status, body, opts...) | 设置接口的返回值;body 为 string、[]byte 时原样发送,其他值编码为 JSON |
srv.Fail(op, code, drimtest.FailOptions{...}) | 让接口返回错误,默认一次;Status 默认按错误码,RetryAfter 为响应头,Times 为 -1 时一直有效 |
srv.Handle(op, handler, opts...) | 用自己的 http.HandlerFunc 处理 |
drimtest.Times(n) | Respond、Handle 的选项:只生效 n 次。后设置的规则先用 |
srv.RevokeTokens() | 让已签发的 App Token 全部失效 |
srv.Calls(op)、srv.Requests() | 收到的请求:Op、Method、Path、Query、Header、Body,JSON() 解析请求体 |
op 是接口名,如 users.create、messages.send,换取令牌为 app.token;每个方法的接口名见服务与方法。没有设置返回值的接口,有响应体的返回 200 和 {},没有响应体的返回 204。
使用独立频道
以下 client 与 ctx 沿用已创建的服务端客户端和调用上下文:
func issueMeetingTickets(ctx context.Context, client *drim.Client) (*drim.RTCChannelTicketsResult, error) {
created, err := client.Channels.Create(ctx, "meeting-demo", drim.RTCChannelCreateParams{
Name: "项目会议", Media: "video", AccessMode: "ticket",
})
if err != nil { return nil, err }
return client.Channels.IssueTickets(ctx, created.Channel.ChannelID, drim.RTCChannelIssueTicketsParams{
Users: []drim.ChannelsIssueTicketsUsers{
{Username: "alice", Role: "publisher"},
},
})
}
// 调用方经已鉴权业务接口交付返回的票据,勿打印原文。参数使用 RTCChannel…Params,响应为对应模型及包装;期限 Field[time.Time] 保留不传 / Null / Set。用量更正为 Channels.UsageAdjustments。分页使用返回的 NextCursor 手动继续,没有频道 All 方法。签发不自动重试;同一移出 / 撤票操作保持 OperationID。新制品尚未发布,接口映射见频道方法。
