package tencentim import ( "bytes" "context" "crypto/rand" "encoding/binary" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( // DefaultEndpoint 是腾讯云 IM 国内 REST 默认入口;海外部署可在配置中覆盖为对应地域域名。 DefaultEndpoint = "console.tim.qq.com" // DefaultGroupType 使用 ChatRoom,保留消息历史能力;AVChatRoom 不适合需要补偿和漫游的房间消息。 DefaultGroupType = "ChatRoom" // 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 string PayloadJSON json.RawMessage } // AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。 type AccountProfile struct { UserID int64 Nick string FaceURL string } // RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。 type RoomEvent struct { 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"` Attributes map[string]string `json:"attributes,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.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") } eventPayload, err := json.Marshal(event) if err != nil { return err } return c.PublishGroupCustomMessage(ctx, CustomGroupMessage{ GroupID: event.RoomID, 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 } payload := bytes.TrimSpace(message.PayloadJSON) if message.ToAccount == "" || message.EventID == "" || len(payload) == 0 { return fmt.Errorf("user custom message is incomplete") } request := sendC2CMsgRequest{ SyncOtherMachine: 2, 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"` } 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 fmt.Errorf("tencent im rest failed: code=%d info=%s", r.ErrorCode, 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"` }