hyapp-server/services/user-service/internal/service/host/private_message_history.go

234 lines
8.5 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 host
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
)
const (
privateMessageHistoryHours = 7 * 24
privateMessageHistoryReadyDelay = 2 * time.Hour
privateMessageHistoryRetryDelay = 10 * time.Minute
privateMessageHistoryImportMax = 500
privateMessageHistoryEmptyMaxWait = 4 * time.Hour
)
// PrivateMessageHistoryClient 是腾讯 IM 离线归档边界;它只在 cron 批处理中调用,
// 不参与客户端发送、接收或 UserSig 签发链路。
type PrivateMessageHistoryClient interface {
ListC2CHistoryFiles(ctx context.Context, hourStartUTC time.Time) ([]tencentim.C2CHistoryFile, error)
StreamC2CHistory(ctx context.Context, file tencentim.C2CHistoryFile, consume func(tencentim.C2CHistoryMessage) error) (int, error)
}
// PrivateMessageHistoryRepository 保存小时同步租约和最小私聊统计事实。
type PrivateMessageHistoryRepository interface {
PreparePrivateMessageHistoryHours(ctx context.Context, sdkAppID int64, fromHourMS int64, throughHourMS int64, nowMS int64) error
ClaimPrivateMessageHistoryHour(ctx context.Context, sdkAppID int64, runID string, fromHourMS int64, throughHourMS int64, nowMS int64, lockTTL time.Duration) (hourStartMS int64, attempts int, claimed bool, err error)
ImportPrivateMessageEvents(ctx context.Context, events []hostdomain.PrivateMessageEvent) (int, error)
CompletePrivateMessageHistoryHour(ctx context.Context, sdkAppID int64, hourStartMS int64, runID string, sourceMessages int, importedEvents int, nowMS int64) error
RetryPrivateMessageHistoryHour(ctx context.Context, sdkAppID int64, hourStartMS int64, runID string, retryAtMS int64, errorMessage string, nowMS int64) error
}
// PrivateMessageHistoryBatchResult 汇总一次小时归档处理,不把下载地址或消息正文带到 cron 日志。
type PrivateMessageHistoryBatchResult struct {
Claimed bool
SourceCount int
ImportedCount int
RetryPending bool
}
// WithPrivateMessageHistorySource 挂载腾讯归档和 owner repository。SDKAppID 是共享归档主键,
// 即使 cron 按多个 app_code 并发触发,同一小时也只会被认领一次。
func WithPrivateMessageHistorySource(sdkAppID int64, client PrivateMessageHistoryClient) Option {
return func(s *Service) {
if sdkAppID <= 0 || client == nil {
return
}
repository, ok := s.repository.(PrivateMessageHistoryRepository)
if !ok {
return
}
s.privateMessageHistorySDKAppID = sdkAppID
s.privateMessageHistoryClient = client
s.privateMessageHistoryRepository = repository
}
}
// ProcessPrivateMessageHistoryBatch 处理最近七天中一个已完成的 UTC 小时。
// get_history 文件由腾讯异步生成,因此统计最多延迟约两小时,但不会给 IM 发消息增加等待。
func (s *Service) ProcessPrivateMessageHistoryBatch(ctx context.Context, runID string, lockTTL time.Duration, importBatchSize int) (PrivateMessageHistoryBatchResult, error) {
if s == nil || s.privateMessageHistoryClient == nil || s.privateMessageHistoryRepository == nil || s.privateMessageHistorySDKAppID <= 0 {
return PrivateMessageHistoryBatchResult{}, xerr.New(xerr.Unavailable, "private message history sync is not configured")
}
runID = strings.TrimSpace(runID)
if runID == "" {
return PrivateMessageHistoryBatchResult{}, xerr.New(xerr.InvalidArgument, "run_id is required")
}
if importBatchSize <= 0 || importBatchSize > privateMessageHistoryImportMax {
importBatchSize = privateMessageHistoryImportMax
}
if lockTTL <= 0 {
lockTTL = 5 * time.Minute
}
now := s.now().UTC()
throughHour := now.Truncate(time.Hour).Add(-privateMessageHistoryReadyDelay)
fromHour := throughHour.Add(-time.Duration(privateMessageHistoryHours-1) * time.Hour)
if err := s.privateMessageHistoryRepository.PreparePrivateMessageHistoryHours(
ctx,
s.privateMessageHistorySDKAppID,
fromHour.UnixMilli(),
throughHour.UnixMilli(),
now.UnixMilli(),
); err != nil {
return PrivateMessageHistoryBatchResult{}, err
}
hourStartMS, attempts, claimed, err := s.privateMessageHistoryRepository.ClaimPrivateMessageHistoryHour(
ctx,
s.privateMessageHistorySDKAppID,
runID,
fromHour.UnixMilli(),
throughHour.UnixMilli(),
now.UnixMilli(),
lockTTL,
)
if err != nil || !claimed {
return PrivateMessageHistoryBatchResult{}, err
}
result := PrivateMessageHistoryBatchResult{Claimed: true}
hourStart := time.UnixMilli(hourStartMS).UTC()
files, err := s.privateMessageHistoryClient.ListC2CHistoryFiles(ctx, hourStart)
if err != nil {
if tencentim.IsRESTErrorCode(err, 1004) {
// 1004 同时表示“无消息”和“文件尚未生成”。最近小时先重试;超过生成窗口后
// 视为空小时完成,避免一个永久空小时阻塞七天回补。
if attempts < 3 && now.Sub(hourStart) < privateMessageHistoryEmptyMaxWait {
if retryErr := s.markPrivateMessageHistoryRetry(ctx, hourStartMS, runID, err, now); retryErr != nil {
return result, retryErr
}
result.RetryPending = true
return result, nil
}
if completeErr := s.privateMessageHistoryRepository.CompletePrivateMessageHistoryHour(
ctx, s.privateMessageHistorySDKAppID, hourStartMS, runID, 0, 0, now.UnixMilli(),
); completeErr != nil {
return result, completeErr
}
return result, nil
}
if retryErr := s.markPrivateMessageHistoryRetry(ctx, hourStartMS, runID, err, now); retryErr != nil {
return result, retryErr
}
return result, err
}
pending := make([]hostdomain.PrivateMessageEvent, 0, importBatchSize)
flush := func() error {
if len(pending) == 0 {
return nil
}
imported, importErr := s.privateMessageHistoryRepository.ImportPrivateMessageEvents(ctx, pending)
if importErr != nil {
return importErr
}
result.ImportedCount += imported
pending = pending[:0]
return nil
}
consume := func(message tencentim.C2CHistoryMessage) error {
event, ok := privateMessageEventFromHistory(message)
if !ok {
return nil
}
pending = append(pending, event)
if len(pending) >= importBatchSize {
return flush()
}
return nil
}
for _, file := range files {
processed, streamErr := s.privateMessageHistoryClient.StreamC2CHistory(ctx, file, consume)
result.SourceCount += processed
if streamErr != nil {
if retryErr := s.markPrivateMessageHistoryRetry(ctx, hourStartMS, runID, streamErr, now); retryErr != nil {
return result, retryErr
}
return result, streamErr
}
}
if err := flush(); err != nil {
if retryErr := s.markPrivateMessageHistoryRetry(ctx, hourStartMS, runID, err, now); retryErr != nil {
return result, retryErr
}
return result, err
}
if err := s.privateMessageHistoryRepository.CompletePrivateMessageHistoryHour(
ctx,
s.privateMessageHistorySDKAppID,
hourStartMS,
runID,
result.SourceCount,
result.ImportedCount,
s.now().UTC().UnixMilli(),
); err != nil {
return result, err
}
return result, nil
}
func (s *Service) markPrivateMessageHistoryRetry(ctx context.Context, hourStartMS int64, runID string, cause error, now time.Time) error {
retryErr := s.privateMessageHistoryRepository.RetryPrivateMessageHistoryHour(
ctx,
s.privateMessageHistorySDKAppID,
hourStartMS,
runID,
now.Add(privateMessageHistoryRetryDelay).UnixMilli(),
cause.Error(),
now.UnixMilli(),
)
if retryErr == nil {
return nil
}
return errors.Join(cause, retryErr)
}
func privateMessageEventFromHistory(message tencentim.C2CHistoryMessage) (hostdomain.PrivateMessageEvent, bool) {
senderText := strings.TrimSpace(message.FromAccount)
targetText := strings.TrimSpace(message.ToAccount)
senderUserID, senderErr := strconv.ParseInt(senderText, 10, 64)
targetUserID, targetErr := strconv.ParseInt(targetText, 10, 64)
if senderErr != nil || targetErr != nil || senderUserID <= 0 || targetUserID <= 0 || message.Timestamp <= 0 {
// 共享 SDKAppID 可能包含管理员/第三方非数字 identifier只有项目内部不可变 user_id
// 才能安全映射 users 主键,其余消息不属于 Host Center 统计事实。
return hostdomain.PrivateMessageEvent{}, false
}
identity := fmt.Sprintf(
"%s\x00%s\x00%d\x00%d\x00%d",
senderText,
targetText,
message.Timestamp,
message.Sequence,
message.Random,
)
hash := sha256.Sum256([]byte(identity))
return hostdomain.PrivateMessageEvent{
EventID: "im-history:" + hex.EncodeToString(hash[:]),
SenderUserID: senderUserID,
TargetUserID: targetUserID,
OccurredAtMS: message.Timestamp * int64(time.Second/time.Millisecond),
}, true
}