package tencentim import ( "compress/gzip" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" ) const ( // historyFileCommand 获取一个自然小时的 C2C 消息归档下载地址;它是离线统计接口, // 不进入客户端发消息链路,也不会把业务服务延迟传播到腾讯 IM。 historyFileCommand = "v4/open_msg_svc/get_history" // 小时归档通常是 MB 级文件,不能复用实时 REST 调用的 5 秒传输预算; // cron RPC 仍有 2 分钟总 deadline,这里只放宽单文件下载而不影响发消息接口。 historyDownloadTimeout = 60 * time.Second ) var tencentHistoryLocation = time.FixedZone("UTC+8", 8*60*60) // C2CHistoryFile 是腾讯 IM 返回的短期下载凭据。URL 含临时授权信息,只能在内存中使用, // 调用方不得把它写入日志、数据库或业务错误。 type C2CHistoryFile struct { URL string `json:"URL"` FileSize int64 `json:"FileSize"` GzipSize int64 `json:"GzipSize"` } // C2CHistoryMessage 只解析私聊人数统计所需字段。MsgBody 等正文由流式 decoder 跳过, // 避免消息内容进入 user-service 内存对象、日志或统计库。 type C2CHistoryMessage struct { FromAccount string `json:"From_Account"` ToAccount string `json:"To_Account"` Timestamp int64 `json:"MsgTimestamp"` Sequence uint64 `json:"MsgSeq"` Random uint64 `json:"MsgRandom"` } type historyFilesRequest struct { ChatType string `json:"ChatType"` MsgTime string `json:"MsgTime"` } type historyFilesResponse struct { restResponse Files []C2CHistoryFile `json:"File"` } // ListC2CHistoryFiles 获取指定 UTC 小时对应的 C2C 归档。 // 腾讯接口的 MsgTime 使用 UTC+8 小时字符串,而消息本身仍携带 Unix 时间戳;在 REST // 边界集中转换可防止 cron 把 UTC 小时错取成前一天的数据。 func (c *RESTClient) ListC2CHistoryFiles(ctx context.Context, hourStartUTC time.Time) ([]C2CHistoryFile, error) { hourStartUTC = hourStartUTC.UTC().Truncate(time.Hour) request := historyFilesRequest{ ChatType: "C2C", MsgTime: hourStartUTC.In(tencentHistoryLocation).Format("2006010215"), } var response historyFilesResponse if err := c.post(ctx, historyFileCommand, request, &response); err != nil { return nil, err } if err := response.err(); err != nil { return nil, err } files := make([]C2CHistoryFile, 0, len(response.Files)) for _, file := range response.Files { file.URL = strings.TrimSpace(file.URL) if file.URL == "" { continue } files = append(files, file) } return files, nil } // StreamC2CHistory 下载并逐条解码一个 gzip 归档。decoder 只保留当前消息的统计字段, // 即使单小时归档很大也不会把整个 MsgList 或消息正文一次性加载进内存。 func (c *RESTClient) StreamC2CHistory(ctx context.Context, file C2CHistoryFile, consume func(C2CHistoryMessage) error) (int, error) { if c == nil || c.httpClient == nil { return 0, fmt.Errorf("tencent im rest client is not configured") } if consume == nil { return 0, fmt.Errorf("tencent im history consumer is required") } downloadURL, err := url.Parse(strings.TrimSpace(file.URL)) if err != nil || downloadURL.Scheme != "https" || downloadURL.Host == "" { return 0, fmt.Errorf("tencent im history download url is invalid") } request, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL.String(), nil) if err != nil { return 0, fmt.Errorf("build tencent im history request: %w", err) } downloadClient := *c.httpClient if downloadClient.Timeout <= 0 || downloadClient.Timeout < historyDownloadTimeout { downloadClient.Timeout = historyDownloadTimeout } response, err := downloadClient.Do(request) if err != nil { var transportErr *url.Error if errors.As(err, &transportErr) { // url.Error 会把带临时下载签名的完整 URL 拼进错误;只保留底层网络原因。 return 0, fmt.Errorf("tencent im history download failed: %w", transportErr.Err) } return 0, fmt.Errorf("tencent im history download failed: %w", err) } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return 0, fmt.Errorf("tencent im history download status %d", response.StatusCode) } archive, err := gzip.NewReader(response.Body) if err != nil { return 0, fmt.Errorf("open tencent im history gzip: %w", err) } defer archive.Close() decoder := json.NewDecoder(archive) first, err := decoder.Token() if err != nil { return 0, fmt.Errorf("decode tencent im history document: %w", err) } if delimiter, ok := first.(json.Delim); !ok || delimiter != '{' { return 0, fmt.Errorf("tencent im history document is not an object") } processed := 0 for decoder.More() { keyToken, err := decoder.Token() if err != nil { return processed, fmt.Errorf("decode tencent im history field: %w", err) } key, ok := keyToken.(string) if !ok { return processed, fmt.Errorf("tencent im history field name is invalid") } if key != "MsgList" { // 元数据字段与统计无关;RawMessage 只短暂承载单个标量/小对象,不读取消息正文。 var ignored json.RawMessage if err := decoder.Decode(&ignored); err != nil { return processed, fmt.Errorf("skip tencent im history metadata: %w", err) } continue } listStart, err := decoder.Token() if err != nil { return processed, fmt.Errorf("decode tencent im history list: %w", err) } if delimiter, ok := listStart.(json.Delim); !ok || delimiter != '[' { return processed, fmt.Errorf("tencent im history MsgList is not an array") } for decoder.More() { var message C2CHistoryMessage if err := decoder.Decode(&message); err != nil { return processed, fmt.Errorf("decode tencent im history message: %w", err) } if err := consume(message); err != nil { return processed, err } processed++ } if _, err := decoder.Token(); err != nil { return processed, fmt.Errorf("close tencent im history list: %w", err) } } if _, err := decoder.Token(); err != nil { return processed, fmt.Errorf("close tencent im history document: %w", err) } return processed, nil }