hyapp-server/pkg/tencentim/rest_client.go
2026-07-04 00:09:06 +08:00

623 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tencentim
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
// DefaultEndpoint 是腾讯云 IM 国内 REST 默认入口;海外部署可在配置中覆盖为对应地域域名。
DefaultEndpoint = "console.tim.qq.com"
// DefaultGroupType 使用 ChatRoom保留消息历史能力AVChatRoom 不适合需要补偿和漫游的房间消息。
DefaultGroupType = "ChatRoom"
// BroadcastGroupType 使用 AVChatRoom直播群ChatRoom 受套餐成员上限约束(标准版 2000 人,
// 已在线上触发 10038 拒绝加群AVChatRoom 成员无上限、进出免审批,播报横幅也不需要漫游补偿。
BroadcastGroupType = "AVChatRoom"
// createGroupCommand 是建群 REST 命令;房间群、全局播报群、区域播报群都复用这一个能力。
createGroupCommand = "v4/group_open_http_svc/create_group"
// sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
sendC2CMsgCommand = "v4/openim/sendmsg"
// accountImportCommand 把内部 user_id 导入腾讯 IM 账号体系C2C 发送和 SDK 登录依赖该账号存在。
accountImportCommand = "v4/im_open_login_svc/account_import"
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member"
// kickUserCommand 让指定账号现有 IM 登录态失效;被封禁用户需要立即从腾讯 IM SDK 下线。
kickUserCommand = "v4/im_open_login_svc/kick"
)
// RESTConfig 保存服务端调用腾讯云 IM REST API 所需配置。
type RESTConfig struct {
// SDKAppID 是腾讯云 IM 应用 ID回调校验和 UserSig 签发都必须使用同一个值。
SDKAppID int64
// SecretKey 只用于服务端签 admin UserSig不能返回给客户端或写入错误日志。
SecretKey string
// AdminIdentifier 是腾讯云控制台配置的管理员账号,播报群消息用它证明“服务端发送”。
AdminIdentifier string
// AdminUserSigTTL 控制 REST 调用签名有效期;过短会增加签发频率,过长会扩大泄露影响面。
AdminUserSigTTL time.Duration
// Endpoint 是 REST 地域入口,例如 adminapisgp.im.qcloud.com不要把地域选择写死在业务里。
Endpoint string
// GroupType 是房间和播报群共用的腾讯群类型;当前保持 ChatRoom 以保留历史能力。
GroupType string
// HTTPClient 允许测试注入 fake transport也让上层统一设置超时。
HTTPClient *http.Client
}
// GroupSpec 是项目内部的建群请求,屏蔽腾讯 REST 字段命名差异。
type GroupSpec struct {
GroupID string
Name string
OwnerAccount string
Type string
ApplyJoinOption string
}
// CustomGroupMessage 表示一条服务端发出的 TIMCustomElem 群消息。
// PayloadJSON 同时写入 Data 和 CloudCustomData客户端和排障侧都能按 event_id 做幂等和追踪。
type CustomGroupMessage struct {
GroupID string
EventID string
Desc string
Ext string
FromAccount string
PayloadJSON json.RawMessage
}
// CustomUserMessage 表示一条服务端发给单个用户的 TIMCustomElem。
// PayloadJSON 同时写入 Data 和 CloudCustomData客户端必须按 event_id 去重。
type CustomUserMessage struct {
ToAccount string
EventID string
Desc string
Ext string
// FromAccount 为空时使用管理员账号;用户主动行为要显式传用户 identifier避免消息归属变成系统通知。
FromAccount string
// SyncOtherMachine 透传腾讯 C2C 同步策略0 保持历史默认 2不同步发送方其他终端。
SyncOtherMachine int
PayloadJSON json.RawMessage
}
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
type AccountProfile struct {
UserID int64
Nick string
FaceURL string
}
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
type RoomEvent struct {
GroupID string `json:"-"`
EventID string `json:"event_id"`
RoomID string `json:"room_id"`
EventType string `json:"event_type"`
ActorUserID int64 `json:"actor_user_id,omitempty"`
TargetUserID int64 `json:"target_user_id,omitempty"`
SeatNo int32 `json:"seat_no,omitempty"`
GiftValue int64 `json:"gift_value,omitempty"`
RoomHeat int64 `json:"room_heat,omitempty"`
RoomVersion int64 `json:"room_version,omitempty"`
Text string `json:"text,omitempty"`
// EntryVehicle 是用户进房瞬间的座驾快照;客户端收到 room_user_joined 后直接播放,不再反查 wallet。
EntryVehicle *RoomEntryVehicleSnapshot `json:"entry_vehicle,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
func (event RoomEvent) MarshalJSON() ([]byte, error) {
type roomEventAlias RoomEvent
payload, err := json.Marshal(roomEventAlias(event))
if err != nil {
return nil, err
}
var merged map[string]any
if err := json.Unmarshal(payload, &merged); err != nil {
return nil, err
}
for key, value := range event.Attributes {
if strings.TrimSpace(key) == "" {
continue
}
if _, exists := merged[key]; exists {
// 顶层协议字段由 RoomEvent 结构体负责attributes 只能补充展示字段,不能覆盖事件身份。
continue
}
merged[key] = value
}
return json.Marshal(merged)
}
// RoomEntryVehicleSnapshot 是房间系统消息里可直接渲染的座驾素材快照。
// 它故意不包含 wallet 内部状态,只保留客户端展示和过期兜底需要的字段。
type RoomEntryVehicleSnapshot struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code,omitempty"`
Name string `json:"name,omitempty"`
AssetURL string `json:"asset_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
AnimationURL string `json:"animation_url,omitempty"`
MetadataJSON string `json:"metadata_json,omitempty"`
EntitlementID string `json:"entitlement_id,omitempty"`
ExpiresAtMS int64 `json:"expires_at_ms,omitempty"`
}
// RESTClient 封装腾讯云 IM REST API把 UserSig、endpoint query 和错误码处理收敛在一个边界。
type RESTClient struct {
// cfg 初始化后不再修改,因此同一个 client 可以被多个 worker 并发复用。
cfg RESTConfig
// httpClient 复用 keep-alive避免高频播报时每条消息都重新建连接。
httpClient *http.Client
}
// NewRESTClient 校验必需配置并创建 REST client。
func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
if cfg.SDKAppID <= 0 || cfg.SecretKey == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
// REST API 依赖管理员账号和 admin UserSig缺任何一个都不能伪造成功。
return nil, fmt.Errorf("tencent im rest config is incomplete")
}
if cfg.AdminUserSigTTL <= 0 {
cfg.AdminUserSigTTL = 24 * time.Hour
}
if strings.TrimSpace(cfg.Endpoint) == "" {
cfg.Endpoint = DefaultEndpoint
}
if strings.TrimSpace(cfg.GroupType) == "" {
cfg.GroupType = DefaultGroupType
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
return &RESTClient{
cfg: cfg,
httpClient: cfg.HTTPClient,
}, nil
}
// EnsureRoomGroup 为房间创建腾讯云 IM 群。
// ownerUserID 暂不映射为腾讯群主,房间状态 owner 仍是 Room Cell腾讯群只做投递通道。
func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error {
roomID = strings.TrimSpace(roomID)
if roomID == "" {
return fmt.Errorf("room_id is required")
}
_ = ownerUserID
return c.EnsureGroup(ctx, GroupSpec{
GroupID: roomID,
Name: roomID,
Type: c.cfg.GroupType,
ApplyJoinOption: "FreeAccess",
})
}
// EnsureGroup 创建服务端拥有的腾讯云 IM 群,并把“自定义 GroupID 已存在”视为成功。
// 这样启动 reconciler、CreateRoom 重试或发布补偿重复执行时,都不会因为已建群而中断主流程。
func (c *RESTClient) EnsureGroup(ctx context.Context, spec GroupSpec) error {
spec.GroupID = strings.TrimSpace(spec.GroupID)
spec.Name = strings.TrimSpace(spec.Name)
spec.Type = strings.TrimSpace(spec.Type)
spec.ApplyJoinOption = strings.TrimSpace(spec.ApplyJoinOption)
spec.OwnerAccount = strings.TrimSpace(spec.OwnerAccount)
if spec.GroupID == "" {
return fmt.Errorf("group_id is required")
}
if spec.Name == "" {
spec.Name = spec.GroupID
}
if spec.Type == "" {
spec.Type = c.cfg.GroupType
}
if spec.Type == BroadcastGroupType {
// AVChatRoom 不支持 ApplyJoinOption带上会被腾讯以非法参数拒绝。
spec.ApplyJoinOption = ""
} else if spec.ApplyJoinOption == "" {
spec.ApplyJoinOption = "FreeAccess"
}
request := createGroupRequest{
Type: spec.Type,
GroupID: spec.GroupID,
Name: spec.Name,
OwnerAccount: spec.OwnerAccount,
ApplyJoinOption: spec.ApplyJoinOption,
}
var response restResponse
if err := c.post(ctx, createGroupCommand, request, &response); err != nil {
return err
}
if response.ErrorCode == 10021 || response.ErrorCode == 10025 {
// 腾讯云不同接口版本可能返回 10021/10025 表示群已存在;建群语义要求幂等。
return nil
}
return response.err()
}
// PublishRoomEvent 把房间系统事件转换为腾讯云群自定义消息。
func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) error {
if strings.TrimSpace(event.RoomID) == "" || strings.TrimSpace(event.EventID) == "" {
// event_id 是客户端去重、日志追踪和补偿投递判断的共同锚点。
return fmt.Errorf("room event is incomplete")
}
groupID := strings.TrimSpace(event.GroupID)
if groupID == "" {
groupID = event.RoomID
}
eventPayload, err := json.Marshal(event)
if err != nil {
return err
}
return c.PublishGroupCustomMessage(ctx, CustomGroupMessage{
GroupID: groupID,
EventID: event.EventID,
Desc: event.EventType,
Ext: "room_system_message",
PayloadJSON: eventPayload,
})
}
// PublishGroupCustomMessage 向任意服务端拥有的腾讯云 IM 群发送 TIMCustomElem。
// 该方法不判断业务类型,调用方必须已经通过 room/broadcast service 完成权限、幂等和持久化决策。
func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message CustomGroupMessage) error {
message.GroupID = strings.TrimSpace(message.GroupID)
message.EventID = strings.TrimSpace(message.EventID)
message.Desc = strings.TrimSpace(message.Desc)
message.Ext = strings.TrimSpace(message.Ext)
message.FromAccount = strings.TrimSpace(message.FromAccount)
payload := bytes.TrimSpace(message.PayloadJSON)
if message.GroupID == "" || message.EventID == "" || len(payload) == 0 {
return fmt.Errorf("group custom message is incomplete")
}
request := sendGroupMsgRequest{
GroupID: message.GroupID,
Random: randomUint32(),
CloudCustomData: string(payload),
FromAccount: message.FromAccount,
MsgBody: []messageElement{
{
MsgType: "TIMCustomElem",
MsgContent: customMsgContent{
Data: string(payload),
Desc: message.Desc,
Ext: message.Ext,
Sound: "",
},
},
},
}
var response restResponse
if err := c.post(ctx, sendGroupMsgCommand, request, &response); err != nil {
return err
}
return response.err()
}
// PublishUserCustomMessage 向单个腾讯云 IM identifier 发送 TIMCustomElem。
// 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。
func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error {
message.ToAccount = strings.TrimSpace(message.ToAccount)
message.EventID = strings.TrimSpace(message.EventID)
message.Desc = strings.TrimSpace(message.Desc)
message.Ext = strings.TrimSpace(message.Ext)
message.FromAccount = strings.TrimSpace(message.FromAccount)
if message.FromAccount == "" {
// C2C 服务端消息默认从管理员账号发出,避免调用方每个业务模块复制同一配置。
message.FromAccount = c.cfg.AdminIdentifier
}
syncOtherMachine := message.SyncOtherMachine
if syncOtherMachine == 0 {
// 历史 notice 消息只投递给目标用户;不显式传值时继续保持这个行为,避免改变钱包/CP 通知的漫游面。
syncOtherMachine = 2
}
payload := bytes.TrimSpace(message.PayloadJSON)
if message.ToAccount == "" || message.EventID == "" || len(payload) == 0 {
return fmt.Errorf("user custom message is incomplete")
}
request := sendC2CMsgRequest{
SyncOtherMachine: syncOtherMachine,
ToAccount: message.ToAccount,
MsgRandom: randomUint32(),
CloudCustomData: string(payload),
FromAccount: message.FromAccount,
MsgBody: []messageElement{
{
MsgType: "TIMCustomElem",
MsgContent: customMsgContent{
Data: string(payload),
Desc: message.Desc,
Ext: message.Ext,
Sound: "",
},
},
},
}
var response restResponse
if err := c.post(ctx, sendC2CMsgCommand, request, &response); err != nil {
return err
}
return response.err()
}
// ImportAccount 确保内部 user_id 已存在于腾讯 IM 账号体系。
// 腾讯接口本身没有本地事务语义,因此调用方应在用户事务提交后调用,并允许后续 UserSig 入口补偿。
func (c *RESTClient) ImportAccount(ctx context.Context, profile AccountProfile) error {
if profile.UserID <= 0 {
return fmt.Errorf("im account import is incomplete")
}
request := accountImportRequest{
UserID: FormatUserID(profile.UserID),
Nick: strings.TrimSpace(profile.Nick),
FaceURL: strings.TrimSpace(profile.FaceURL),
}
var response restResponse
if err := c.post(ctx, accountImportCommand, request, &response); err != nil {
return err
}
if response.ErrorCode == 0 || isAccountAlreadyImported(response) {
return nil
}
return response.err()
}
// DestroyGroup 解散一个腾讯云 IM 群。
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
groupID = strings.TrimSpace(groupID)
if groupID == "" {
return fmt.Errorf("group_id is required")
}
request := destroyGroupRequest{GroupID: groupID}
var response restResponse
if err := c.post(ctx, destroyGroupCommand, request, &response); err != nil {
return err
}
return response.err()
}
// DeleteGroupMember 从指定腾讯云 IM 群移除一个用户。
// 它只改变“这个用户在这个群里的成员关系”,不会删除群,也不会影响其他成员。
func (c *RESTClient) DeleteGroupMember(ctx context.Context, groupID string, userID int64) error {
groupID = strings.TrimSpace(groupID)
if groupID == "" || userID <= 0 {
return fmt.Errorf("group member removal is incomplete")
}
request := deleteGroupMemberRequest{
GroupID: groupID,
MemberToDelAccount: []string{FormatUserID(userID)},
Silence: 1,
}
var response restResponse
if err := c.post(ctx, deleteGroupMemberCommand, request, &response); err != nil {
return err
}
return response.err()
}
// DeleteRoomGroupMember 从房间 IM 群移除用户;这是 presence 清理的外部副作用,不改变房间权威状态。
func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error {
return c.DeleteGroupMember(ctx, roomID, userID)
}
// KickUser 失效指定用户的腾讯云 IM 登录态。
// 该接口不会禁止未来用新 UserSig 登录,因此调用方必须先在业务状态和 token denylist 上完成封禁。
func (c *RESTClient) KickUser(ctx context.Context, userID int64) error {
if userID <= 0 {
return fmt.Errorf("kick user is incomplete")
}
request := kickUserRequest{UserID: FormatUserID(userID)}
var response restResponse
if err := c.post(ctx, kickUserCommand, request, &response); err != nil {
return err
}
return response.err()
}
// FormatUserID 把内部不可变 user_id 映射成腾讯云账号字符串。
func FormatUserID(userID int64) string {
// 十进制 user_id 跨服务稳定,也避免把可变 display_user_id 暴露给 IM 账号体系。
return strconv.FormatInt(userID, 10)
}
func (c *RESTClient) post(ctx context.Context, command string, payload any, out *restResponse) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(command), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
// 腾讯 REST 正常业务失败通常是 HTTP 200 + ErrorCode非 2xx 视为网络/网关层失败。
return fmt.Errorf("tencent im http status %d: %s", response.StatusCode, string(responseBody))
}
if err := json.Unmarshal(responseBody, out); err != nil {
return err
}
return nil
}
func (c *RESTClient) endpoint(command string) string {
adminSig, _ := GenerateUserSig(UserSigConfig{
SDKAppID: c.cfg.SDKAppID,
SecretKey: c.cfg.SecretKey,
TTL: c.cfg.AdminUserSigTTL,
}, c.cfg.AdminIdentifier, time.Now().UTC())
values := url.Values{}
values.Set("sdkappid", strconv.FormatInt(c.cfg.SDKAppID, 10))
values.Set("identifier", c.cfg.AdminIdentifier)
values.Set("usersig", adminSig.UserSig)
values.Set("random", strconv.FormatUint(uint64(randomUint32()), 10))
values.Set("contenttype", "json")
return "https://" + strings.TrimSuffix(c.cfg.Endpoint, "/") + "/" + strings.TrimPrefix(command, "/") + "?" + values.Encode()
}
func randomUint32() uint32 {
var payload [4]byte
if _, err := rand.Read(payload[:]); err != nil {
// REST random 只需要足够的去重熵;加密随机失败时用纳秒时间兜底。
return uint32(time.Now().UnixNano())
}
return binary.BigEndian.Uint32(payload[:])
}
type restResponse struct {
ActionStatus string `json:"ActionStatus"`
ErrorInfo string `json:"ErrorInfo"`
ErrorCode int `json:"ErrorCode"`
}
// RESTError 保留腾讯云 IM 业务错误码,让上层能区分缺群、参数错误和限流。
type RESTError struct {
Code int
Info string
}
// Error 实现 error沿用既有日志格式避免线上检索规则失效。
func (e RESTError) Error() string {
return fmt.Sprintf("tencent im rest failed: code=%d info=%s", e.Code, e.Info)
}
// IsRESTErrorCode 判断错误链中是否包含指定腾讯云 IM 业务错误码。
func IsRESTErrorCode(err error, codes ...int) bool {
var restErr RESTError
if !errors.As(err, &restErr) {
return false
}
for _, code := range codes {
if restErr.Code == code {
return true
}
}
return false
}
func (r restResponse) err() error {
if r.ErrorCode == 0 && strings.EqualFold(r.ActionStatus, "OK") {
return nil
}
if r.ErrorCode == 0 && r.ActionStatus == "" {
// 部分测试只构造 ErrorCode腾讯语义里 0 即成功,因此允许空 ActionStatus。
return nil
}
return RESTError{Code: r.ErrorCode, Info: r.ErrorInfo}
}
func isAccountAlreadyImported(response restResponse) bool {
// 腾讯不同版本和地域对重复导入的错误码/文案不完全一致;账号已存在是幂等成功。
if response.ErrorCode == 70107 {
return true
}
info := strings.ToLower(strings.TrimSpace(response.ErrorInfo))
return strings.Contains(info, "already") || strings.Contains(info, "exist")
}
type createGroupRequest struct {
OwnerAccount string `json:"Owner_Account,omitempty"`
Type string `json:"Type"`
GroupID string `json:"GroupId"`
Name string `json:"Name"`
ApplyJoinOption string `json:"ApplyJoinOption,omitempty"`
}
type sendGroupMsgRequest struct {
GroupID string `json:"GroupId"`
Random uint32 `json:"Random"`
MsgBody []messageElement `json:"MsgBody"`
CloudCustomData string `json:"CloudCustomData,omitempty"`
FromAccount string `json:"From_Account,omitempty"`
}
type sendC2CMsgRequest struct {
SyncOtherMachine int `json:"SyncOtherMachine"`
FromAccount string `json:"From_Account,omitempty"`
ToAccount string `json:"To_Account"`
MsgLifeTime int `json:"MsgLifeTime,omitempty"`
MsgRandom uint32 `json:"MsgRandom"`
CloudCustomData string `json:"CloudCustomData,omitempty"`
MsgBody []messageElement `json:"MsgBody"`
}
type accountImportRequest struct {
UserID string `json:"UserID"`
Nick string `json:"Nick,omitempty"`
FaceURL string `json:"FaceUrl,omitempty"`
}
type destroyGroupRequest struct {
GroupID string `json:"GroupId"`
}
type deleteGroupMemberRequest struct {
GroupID string `json:"GroupId"`
MemberToDelAccount []string `json:"MemberToDel_Account"`
Silence int `json:"Silence,omitempty"`
Reason string `json:"Reason,omitempty"`
}
type kickUserRequest struct {
UserID string `json:"UserID"`
}
type messageElement struct {
MsgType string `json:"MsgType"`
MsgContent customMsgContent `json:"MsgContent"`
}
type customMsgContent struct {
Data string `json:"Data"`
Desc string `json:"Desc,omitempty"`
Ext string `json:"Ext,omitempty"`
Sound string `json:"Sound,omitempty"`
}