177 lines
6.1 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 (
"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
}