177 lines
6.1 KiB
Go
177 lines
6.1 KiB
Go
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
|
||
}
|