package app import ( "context" "crypto/md5" crand "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "fmt" "math/big" "sort" "strconv" "strings" "sync" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" goredis "github.com/redis/go-redis/v9" "go.uber.org/zap" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/shared/stream" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) const ( defaultTrackingDays = 7 maxTrackingDays = 30 defaultMonitoringListPageSize = 10 maxMonitoringListPageSize = 100 monitoringCollectorType = "desktop" monitoringOnlineDuration = 20 * time.Minute monitoringCollectNowQuestionLimit = 5 monitoringPlatformAuthorizationAuthorized = "authorized" monitoringPlatformAuthorizationNoDesktopClient = "no_desktop_client" monitoringPlatformAuthorizationNoAuthorizedPlatforms = "no_authorized_platforms" ) type MonitoringService struct { businessPool *pgxpool.Pool monitoringPool *pgxpool.Pool rabbitMQ *rabbitmq.Client redis *goredis.Client logger *zap.Logger configMu sync.RWMutex desktopTasksRolloutPercentage int brandLibraryConfig config.BrandLibraryConfig } func NewMonitoringService( businessPool, monitoringPool *pgxpool.Pool, rabbitMQClient *rabbitmq.Client, dispatchCfg config.MonitoringDispatchConfig, brandLibraryCfg config.BrandLibraryConfig, logger *zap.Logger, ) *MonitoringService { return &MonitoringService{ businessPool: businessPool, monitoringPool: monitoringPool, rabbitMQ: rabbitMQClient, logger: logger, desktopTasksRolloutPercentage: dispatchCfg.DesktopTasksRolloutPercentage, brandLibraryConfig: brandLibraryCfg, } } func (s *MonitoringService) WithRedis(redis *goredis.Client) *MonitoringService { if s != nil { s.redis = redis } return s } func (s *MonitoringService) UpdateConfig(dispatchCfg config.MonitoringDispatchConfig, brandLibraryCfg config.BrandLibraryConfig) { if s == nil { return } s.configMu.Lock() s.desktopTasksRolloutPercentage = dispatchCfg.DesktopTasksRolloutPercentage s.brandLibraryConfig = brandLibraryCfg s.configMu.Unlock() } func (s *MonitoringService) currentConfig() (int, config.BrandLibraryConfig) { if s == nil { return 0, config.BrandLibraryConfig{} } s.configMu.RLock() defer s.configMu.RUnlock() return s.desktopTasksRolloutPercentage, s.brandLibraryConfig } type MonitoringDashboardCompositeResponse struct { Overview MonitoringOverview `json:"overview"` Runtime MonitoringDashboardRuntime `json:"runtime"` CitationWindow MonitoringDashboardWindow `json:"citation_window"` PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"` HotQuestions []MonitoringHotQuestion `json:"hot_questions"` HotQuestionsPage MonitoringPage `json:"hot_questions_page"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"` CitedArticlesPage MonitoringPage `json:"cited_articles_page"` } type MonitoringCitationSummaryResponse struct { CitationWindow MonitoringDashboardWindow `json:"citation_window"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"` CitedArticlesPage MonitoringPage `json:"cited_articles_page"` } type MonitoringPage struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` } type MonitoringPageRequest struct { Page int PageSize int Query string } type MonitoringDashboardWindow struct { Days int `json:"days"` From string `json:"from"` To string `json:"to"` } type MonitoringDashboardRuntime struct { CurrentUserClientOnline bool `json:"current_user_client_online"` PlatformAuthorizationStatus string `json:"platform_authorization_status"` AuthorizedMonitoringPlatformIDs []string `json:"authorized_monitoring_platform_ids"` AuthorizedMonitoringPlatformCount int `json:"authorized_monitoring_platform_count"` OnlineAuthorizedMonitoringPlatformIDs []string `json:"online_authorized_monitoring_platform_ids"` } type MonitoringOverview struct { CollectionMode string `json:"collection_mode"` MentionRate *float64 `json:"mention_rate"` Top1MentionRate *float64 `json:"top1_mention_rate"` FirstRecommendRate *float64 `json:"first_recommend_rate"` PositiveMentionRate *float64 `json:"positive_mention_rate"` CitationRate *float64 `json:"citation_rate"` DesiredSampleCount int64 `json:"desired_sample_count"` PlannedSampleCount int64 `json:"planned_sample_count"` ActualSampleCount int64 `json:"actual_sample_count"` CoverageRate *float64 `json:"coverage_rate"` ConfidenceLevel string `json:"confidence_level"` LastSampledAt *string `json:"last_sampled_at"` LastTriggerSource *string `json:"last_trigger_source"` PrevSnapshotMentionRate *float64 `json:"prev_snapshot_mention_rate"` PrevSnapshotTop1MentionRate *float64 `json:"prev_snapshot_top1_mention_rate"` PrevSnapshotFirstRecommendRate *float64 `json:"prev_snapshot_first_recommend_rate"` PrevSnapshotPositiveMentionRate *float64 `json:"prev_snapshot_positive_mention_rate"` } type MonitoringPlatformDaily struct { AIPlatformID string `json:"ai_platform_id"` PlatformName string `json:"platform_name"` MentionRate *float64 `json:"mention_rate"` Top1MentionRate *float64 `json:"top1_mention_rate"` PlatformSampleStatus string `json:"platform_sample_status"` ActualSampleCount int64 `json:"actual_sample_count"` LastSampledAt *string `json:"last_sampled_at"` } type MonitoringHotQuestion struct { QuestionID int64 `json:"question_id"` QuestionHash string `json:"question_hash"` QuestionText string `json:"question_text"` MentionRate *float64 `json:"mention_rate"` LastSampledAt *string `json:"last_sampled_at"` } type MonitoringCitationRanking struct { AIPlatformID string `json:"ai_platform_id"` PlatformName string `json:"platform_name"` SampleStatus string `json:"sample_status"` CitedAnswerCount int64 `json:"cited_answer_count"` CitedArticleCount int64 `json:"cited_article_count"` CitationSourceCount int64 `json:"citation_source_count"` SaaSSourceCount int64 `json:"saas_source_count"` SaaSSourceRate *float64 `json:"saas_source_rate"` CitationRate *float64 `json:"citation_rate"` } type MonitoringCitedArticle struct { ArticleID *int64 `json:"article_id"` ArticleTitle string `json:"article_title"` PublishPlatform string `json:"publish_platform"` SourceType string `json:"source_type"` SourceID int64 `json:"source_id"` SourceURL string `json:"source_url"` CitationCount int64 `json:"citation_count"` CitationRate *float64 `json:"citation_rate"` SourceShare *float64 `json:"source_share"` } type MonitoringQuestionDetailResponse struct { QuestionID int64 `json:"question_id"` QuestionHash string `json:"question_hash"` QuestionText string `json:"question_text"` PlatformAuthorizationStatus string `json:"platform_authorization_status"` TimeWindow MonitoringQuestionDetailTimeWindow `json:"time_window"` Platforms []MonitoringQuestionDetailPlatform `json:"platforms"` CitationAnalysis []MonitoringQuestionCitationStats `json:"citation_analysis"` ContentCitations []MonitoringQuestionContentCitation `json:"content_citations"` } type MonitoringQuestionDetailTimeWindow struct { DateFrom string `json:"date_from"` DateTo string `json:"date_to"` } type MonitoringQuestionDetailPlatform struct { AIPlatformID string `json:"ai_platform_id"` PlatformName string `json:"platform_name"` SampleStatus string `json:"sample_status"` RunID *int64 `json:"run_id"` SampledAt *string `json:"sampled_at"` ProviderModel *string `json:"provider_model"` AnswerText *string `json:"answer_text"` BrandMentioned *bool `json:"brand_mentioned"` BrandMentionPosition *string `json:"brand_mention_position"` Citations []MonitoringQuestionDetailCitation `json:"citations"` InlineCitations []MonitoringSourceItem `json:"inline_citations,omitempty"` } type MonitoringQuestionDetailCitation struct { CitedURL string `json:"cited_url"` CitedTitle *string `json:"cited_title"` SiteName string `json:"site_name"` SiteKey string `json:"site_key"` FaviconURL string `json:"favicon_url"` ArticleID *int64 `json:"article_id"` SourceType *string `json:"source_type,omitempty"` SourceID *int64 `json:"source_id,omitempty"` SourceURL *string `json:"source_url,omitempty"` ArticleTitle *string `json:"article_title,omitempty"` ResolutionStatus string `json:"resolution_status"` ResolutionConfidence string `json:"resolution_confidence"` } type MonitoringQuestionCitationStats struct { AIPlatformID string `json:"ai_platform_id"` PlatformName string `json:"platform_name"` SiteName string `json:"site_name"` SiteKey string `json:"site_key"` SiteDomain string `json:"site_domain"` CitationCount int64 `json:"citation_count"` CitationRate *float64 `json:"citation_rate"` ContentCitationCount int64 `json:"content_citation_count"` } type MonitoringQuestionContentCitation struct { ArticleID *int64 `json:"article_id"` ArticleTitle string `json:"article_title"` PublishPlatform string `json:"publish_platform"` SourceType string `json:"source_type"` SourceID int64 `json:"source_id"` SourceURL string `json:"source_url"` AIPlatformID string `json:"ai_platform_id"` PlatformName string `json:"platform_name"` CitationCount int64 `json:"citation_count"` CitationRate *float64 `json:"citation_rate"` } type MonitoringCollectNowResponse struct { CollectionMode string `json:"collection_mode"` RefreshedTaskCount int64 `json:"refreshed_task_count"` CreatedTaskCount int64 `json:"created_task_count"` LeasedTaskCount int64 `json:"leased_task_count"` CompletedTaskCount int64 `json:"completed_task_count"` HasEffectiveSnapshot bool `json:"has_effective_snapshot"` RequestID string `json:"request_id,omitempty"` TargetClientID string `json:"target_client_id,omitempty"` AffectedTaskCount int64 `json:"affected_task_count,omitempty"` PromotedTaskCount int64 `json:"promoted_task_count,omitempty"` AbortedQueuedCount int64 `json:"aborted_queued_count,omitempty"` InterruptRequestedCount int64 `json:"interrupt_requested_count,omitempty"` InterruptGeneration int `json:"interrupt_generation,omitempty"` TTLExpiresAt string `json:"ttl_expires_at,omitempty"` Message string `json:"message"` } type MonitoringCollectNowOptions struct { PlatformIDs []string Preempt bool WaitForFirstDispatch bool TargetClientID *uuid.UUID } type monitoringQuotaConfig struct { CollectionMode string PrimaryClientID *uuid.UUID EnabledPlatforms []string } func monitoringPlatformAuthorizationStatus(quota monitoringQuotaConfig) string { if quota.PrimaryClientID == nil { return monitoringPlatformAuthorizationNoDesktopClient } if len(quota.EnabledPlatforms) == 0 { return monitoringPlatformAuthorizationNoAuthorizedPlatforms } return monitoringPlatformAuthorizationAuthorized } type monitoringBrandIdentity struct { ID int64 Name string } type monitoringAccessState struct { AccessStatus string } type monitoringLatestPlatformRun struct { PlatformID string RunID int64 CompletedAt sql.NullTime ProviderModel sql.NullString AnswerText sql.NullString BrandMentioned sql.NullBool BrandMentionPosition sql.NullString InlineCitations []MonitoringSourceItem } type monitoringDerivedRateStats struct { Total int64 MentionedCount int64 Top1Count int64 FirstRecommend int64 PositiveMentions int64 } type monitoringDerivedMetrics struct { ByDate map[string]monitoringDerivedRateStats ByDatePlatform map[string]map[string]monitoringDerivedRateStats ByDateQuestion map[string]map[int64]monitoringDerivedRateStats ByQuestion map[int64]monitoringDerivedRateStats } type monitoringDerivedSampleKey struct { Date string PlatformID string QuestionID int64 } type monitoringDerivedParseSample struct { BusinessDate time.Time PlatformID string QuestionID int64 EventAt sql.NullTime RunID int64 Summary monitoringAnswerParseSummary } type monitoringPlatformMetadata struct { ID string Name string } type monitoringConfiguredQuestion struct { ID int64 KeywordID int64 QuestionText string QuestionHash []byte } func monitoringQuestionTextSnapshot(question monitoringConfiguredQuestion) (string, error) { questionText := strings.TrimSpace(question.QuestionText) if questionText == "" { return "", response.ErrInternal(50041, "missing_question_text_snapshot", "configured monitoring question text is empty") } return questionText, nil } var defaultMonitoringPlatforms = []monitoringPlatformMetadata{ {ID: "yuanbao", Name: "混元 / 元宝"}, {ID: "kimi", Name: "Kimi"}, {ID: "wenxin", Name: "文心一言"}, {ID: "deepseek", Name: "DeepSeek"}, {ID: "doubao", Name: "豆包"}, {ID: "qwen", Name: "通义千问"}, } var monitoringPlatformNames = map[string]string{ "deepseek": "DeepSeek", "doubao": "豆包", "kimi": "Kimi", "qwen": "通义千问", "wenxin": "文心一言", "yuanbao": "混元 / 元宝", } func (s *MonitoringService) DashboardComposite( ctx context.Context, brandID int64, keywordID *int64, questionID *int64, days int, businessDate string, aiPlatformID *string, hotQuestionPage MonitoringPageRequest, ) (*MonitoringDashboardCompositeResponse, error) { actor := auth.MustActor(ctx) workspaceID := auth.CurrentWorkspaceID(ctx) brand, err := s.resolveBrand(ctx, actor.TenantID, brandID) if err != nil { return nil, err } if keywordID != nil { if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil { return nil, err } } quota, err := s.loadQuota(ctx, actor, workspaceID) if err != nil { return nil, err } runtime, err := s.loadDashboardRuntime(ctx, actor, workspaceID, quota) if err != nil { return nil, err } configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID) if err != nil { return nil, err } configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID) if err != nil { return nil, err } platforms := defaultMonitoringPlatformMetadata() selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID) filteredPlatforms := filterMonitoringPlatforms(platforms, selectedPlatformID) startDate, endDate, err := resolveDashboardDateWindow(days, businessDate) if err != nil { return nil, err } questionIDs := configuredQuestionIDs(configuredQuestions) derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, questionIDs, brand.Name, startDate, endDate, selectedPlatformID) if err != nil { return nil, err } overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics) if err != nil { return nil, err } accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate) if err != nil { return nil, err } platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, questionIDs, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics) if err != nil { return nil, err } hotQuestions, hotQuestionsPage, err := s.loadHotQuestionsPage( ctx, actor.TenantID, brand.ID, keywordID, questionID, endDate, selectedPlatformID, derivedMetrics, hotQuestionPage, ) if err != nil { return nil, err } return &MonitoringDashboardCompositeResponse{ Overview: overview, Runtime: runtime, CitationWindow: MonitoringDashboardWindow{ Days: defaultTrackingDays, From: endDate.AddDate(0, 0, -(defaultTrackingDays - 1)).Format("2006-01-02"), To: endDate.Format("2006-01-02"), }, PlatformBreakdown: platformBreakdown, HotQuestions: hotQuestions, HotQuestionsPage: hotQuestionsPage, CitationRanking: []MonitoringCitationRanking{}, CitedArticles: []MonitoringCitedArticle{}, CitedArticlesPage: emptyMonitoringPage(normalizeMonitoringPageRequest(MonitoringPageRequest{}, defaultMonitoringListPageSize)), }, nil } func (s *MonitoringService) CitationSummary( ctx context.Context, days int, brandID int64, keywordID *int64, questionID *int64, businessDate string, aiPlatformID *string, citedArticlePage MonitoringPageRequest, ) (*MonitoringCitationSummaryResponse, error) { actor := auth.MustActor(ctx) workspaceID := auth.CurrentWorkspaceID(ctx) var questionIDs []int64 if brandID != 0 { brand, err := s.resolveBrand(ctx, actor.TenantID, brandID) if err != nil { return nil, err } if keywordID != nil { if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil { return nil, err } } configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID) if err != nil { return nil, err } configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID) if err != nil { return nil, err } questionIDs = configuredQuestionIDs(configuredQuestions) } quota, err := s.loadQuota(ctx, actor, workspaceID) if err != nil { return nil, err } citationWindowDays := normalizeCitationWindowDays(days) startDate, endDate, err := resolveDashboardDateWindow(citationWindowDays, businessDate) if err != nil { return nil, err } accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate) if err != nil { return nil, err } selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID) citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, accessStates, selectedPlatformID) if err != nil { return nil, err } citedArticles, citedArticlesPage, err := s.loadCitedArticlesPage(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID, citedArticlePage) if err != nil { return nil, err } return &MonitoringCitationSummaryResponse{ CitationWindow: MonitoringDashboardWindow{ Days: citationWindowDays, From: startDate.Format("2006-01-02"), To: endDate.Format("2006-01-02"), }, CitationRanking: citationRanking, CitedArticles: citedArticles, CitedArticlesPage: citedArticlesPage, }, nil } func (s *MonitoringService) loadDashboardRuntime(ctx context.Context, actor auth.Actor, workspaceID int64, quota monitoringQuotaConfig) (MonitoringDashboardRuntime, error) { online, err := s.hasOnlineClientForUser(ctx, actor.TenantID, workspaceID, actor.UserID) if err != nil { return MonitoringDashboardRuntime{}, err } onlinePlatformIDs, err := s.loadOnlineMonitoringPlatformIDsForUser(ctx, actor.TenantID, workspaceID, actor.UserID, quota.EnabledPlatforms) if err != nil { return MonitoringDashboardRuntime{}, err } return MonitoringDashboardRuntime{ CurrentUserClientOnline: online, PlatformAuthorizationStatus: monitoringPlatformAuthorizationStatus(quota), AuthorizedMonitoringPlatformIDs: append([]string(nil), quota.EnabledPlatforms...), AuthorizedMonitoringPlatformCount: len(quota.EnabledPlatforms), OnlineAuthorizedMonitoringPlatformIDs: onlinePlatformIDs, }, nil } func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questionID int64, dateFrom, dateTo string, questionHash string, aiPlatformID *string) (*MonitoringQuestionDetailResponse, error) { actor := auth.MustActor(ctx) workspaceID := auth.CurrentWorkspaceID(ctx) brand, err := s.resolveBrand(ctx, actor.TenantID, brandID) if err != nil { return nil, err } fromDate, toDate, err := parseDetailDateRange(dateFrom, dateTo) if err != nil { return nil, err } quota, err := s.loadQuota(ctx, actor, workspaceID) if err != nil { return nil, err } selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID) platforms := filterMonitoringPlatforms(defaultMonitoringPlatformMetadata(), selectedPlatformID) questionText, err := s.loadQuestionText(ctx, actor.TenantID, brand.ID, questionID) if err != nil { return nil, err } hashBytes := []byte(nil) if strings.TrimSpace(questionHash) != "" { hashBytes, err = decodeQuestionHash(questionHash) if err != nil { return nil, response.ErrBadRequest(40031, "invalid_question_hash", "question_hash must be a v1 hex digest") } } else { hashBytes, err = s.loadLatestQuestionHash(ctx, actor.TenantID, brand.ID, questionID, fromDate, toDate) if err != nil { return nil, err } } accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, toDate) if err != nil { return nil, err } latestRuns, err := s.loadLatestPlatformRuns(ctx, actor.TenantID, brand.ID, brand.Name, questionID, hashBytes, fromDate, toDate, selectedPlatformID) if err != nil { return nil, err } runIDs := make([]int64, 0, len(latestRuns)) for _, item := range latestRuns { runIDs = append(runIDs, item.RunID) } citationMap, err := s.loadCitationsForRuns(ctx, actor.TenantID, runIDs) if err != nil { return nil, err } platformPayload := make([]MonitoringQuestionDetailPlatform, 0, len(platforms)) for _, platform := range platforms { if selectedPlatformID != nil && platform.ID != *selectedPlatformID { continue } if run, ok := latestRuns[platform.ID]; ok { runID := run.RunID platformPayload = append(platformPayload, MonitoringQuestionDetailPlatform{ AIPlatformID: platform.ID, PlatformName: platform.Name, SampleStatus: "sampled", RunID: &runID, SampledAt: formatNullTime(run.CompletedAt), ProviderModel: stringPointer(run.ProviderModel), AnswerText: stringPointer(run.AnswerText), BrandMentioned: boolPointer(run.BrandMentioned), BrandMentionPosition: stringPointer(run.BrandMentionPosition), Citations: citationMap[run.RunID], InlineCitations: append([]MonitoringSourceItem(nil), run.InlineCitations...), }) continue } platformPayload = append(platformPayload, MonitoringQuestionDetailPlatform{ AIPlatformID: platform.ID, PlatformName: platform.Name, SampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]), Citations: []MonitoringQuestionDetailCitation{}, }) } citationAnalysis, err := s.loadQuestionCitationAnalysis(ctx, actor.TenantID, runIDs) if err != nil { return nil, err } contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, runIDs) if err != nil { return nil, err } return &MonitoringQuestionDetailResponse{ QuestionID: questionID, QuestionHash: encodeQuestionHash(hashBytes), QuestionText: questionText, PlatformAuthorizationStatus: monitoringPlatformAuthorizationStatus(quota), TimeWindow: MonitoringQuestionDetailTimeWindow{ DateFrom: fromDate.Format("2006-01-02"), DateTo: toDate.Format("2006-01-02"), }, Platforms: platformPayload, CitationAnalysis: citationAnalysis, ContentCitations: contentCitations, }, nil } func (s *MonitoringService) CollectNow( ctx context.Context, brandID int64, keywordID *int64, questionID *int64, options MonitoringCollectNowOptions, ) (*MonitoringCollectNowResponse, error) { actor := auth.MustActor(ctx) workspaceID := auth.CurrentWorkspaceID(ctx) brand, err := s.resolveBrand(ctx, actor.TenantID, brandID) if err != nil { return nil, err } if keywordID != nil { if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil { return nil, err } } quota, err := s.loadQuota(ctx, actor, workspaceID) if err != nil { return nil, err } configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID) if err != nil { return nil, err } configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID) if err != nil { return nil, err } limitApplied := false if questionID == nil && len(configuredQuestions) > monitoringCollectNowQuestionLimit { configuredQuestions = configuredQuestions[:monitoringCollectNowQuestionLimit] limitApplied = true } if len(configuredQuestions) == 0 { message := "当前品牌下暂无关键词库(搜索词),未创建采集任务" if keywordID != nil { message = "当前关键词库(搜索词)下暂无搜索词,未创建采集任务" } if questionID != nil { message = "当前搜索词不可用,未创建采集任务" } return &MonitoringCollectNowResponse{ CollectionMode: quota.CollectionMode, RefreshedTaskCount: 0, CreatedTaskCount: 0, LeasedTaskCount: 0, CompletedTaskCount: 0, HasEffectiveSnapshot: true, Message: message, }, nil } platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms) platforms, err = intersectMonitoringPlatforms(platforms, options.PlatformIDs) if err != nil { return nil, err } if len(platforms) == 0 { return &MonitoringCollectNowResponse{ CollectionMode: quota.CollectionMode, RefreshedTaskCount: 0, CreatedTaskCount: 0, LeasedTaskCount: 0, CompletedTaskCount: 0, HasEffectiveSnapshot: true, Message: "当前桌面端暂无已授权的 AI 平台,未创建采集任务", }, nil } questionIDs := configuredQuestionIDs(configuredQuestions) platformIDs := monitoringPlatformIDs(platforms) targetClientIDsByPlatform, err := s.loadRandomOnlineMonitoringClientIDsByPlatformForUser(ctx, actor.TenantID, workspaceID, actor.UserID, platformIDs) if err != nil { return nil, err } platforms = filterMonitoringPlatformsByIDSet(platforms, platformIDsFromClientTargetMap(targetClientIDsByPlatform)) if len(platforms) == 0 { return nil, response.ErrConflict(40941, "desktop_client_offline", "current user's monitoring desktop client is offline for selected platforms") } platformIDs = monitoringPlatformIDs(platforms) dispatchTargetClientIDs := collectNowDispatchTargetClientIDs(targetClientIDsByPlatform, nil) executionOwner := "legacy" if s.shouldUseDesktopTasksExecution(actor.TenantID) { executionOwner = "desktop_tasks" } clientID, err := s.resolveCollectNowClientID( ctx, actor.TenantID, workspaceID, actor.UserID, quota.PrimaryClientID, options.TargetClientID, platformIDs, ) if err != nil { return nil, err } if clientID == nil { return nil, response.ErrConflict(40941, "desktop_client_offline", "current user's monitoring desktop client is offline") } dispatchTargetClientIDs = collectNowDispatchTargetClientIDs(targetClientIDsByPlatform, clientID) now := time.Now().UTC() todayTime, _ := monitoringBusinessDayAndDateAt(now) requestID := uuid.New() ttlExpiresAt := now.Add(monitoringCollectNowTTL) scopeHash := hashMonitoringCollectNowScope(actor.UserID, brand.ID, keywordID, questionIDs, monitoringPlatformIDs(platforms)) tx, err := s.monitoringPool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50041, "begin_failed", "failed to start monitoring collect-now transaction") } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, collectNowScopeLockKey(actor.UserID, brand.ID, scopeHash)); err != nil { return nil, response.ErrInternal(50041, "lock_failed", "failed to acquire collect-now scope lock") } existingRequest, err := s.findActiveCollectNowRequest( ctx, tx, actor.TenantID, workspaceID, brand.ID, actor.UserID, scopeHash, now, ) if err != nil { return nil, err } if existingRequest != nil { base := buildCollectNowResponseFromState(quota.CollectionMode, existingRequest) executing, execErr := s.hasExecutingCollectNowTask(ctx, existingRequest.RequestID) if execErr != nil && s.logger != nil { s.logger.Warn("collect-now executing-state inspection failed", zap.String("request_id", existingRequest.RequestID.String()), zap.Error(execErr), ) } if executing { if base == nil { base = &MonitoringCollectNowResponse{CollectionMode: quota.CollectionMode} } base.Message = "任务正在执行中,请等待" _ = tx.Rollback(ctx) return base, nil } if _, err := tx.Exec(ctx, ` UPDATE monitoring_collect_requests SET superseded_by_request_id = $2, status = CASE WHEN status IN ('accepted', 'dispatching') THEN 'superseded' ELSE status END, updated_at = NOW() WHERE request_id = $1 AND superseded_by_request_id IS NULL `, existingRequest.RequestID, requestID); err != nil { return nil, response.ErrInternal(50041, "request_update_failed", "failed to supersede active collect-now request after client drift") } } if _, err := expireMonitoringLeasedTasks(ctx, tx, &actor.TenantID, now); err != nil { return nil, err } if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil && questionID == nil); err != nil { return nil, err } interruptGeneration, err := s.nextCollectNowInterruptGeneration(ctx, tx, dispatchTargetClientIDs, executionOwner) if err != nil { return nil, err } refreshedCount, createdCount, leasedCount, completedCount, err := s.ensureCollectNowTasks( ctx, tx, actor.TenantID, brand.ID, configuredQuestions, platforms, todayTime, targetClientIDsByPlatform, *clientID, interruptGeneration, executionOwner, ) if err != nil { return nil, err } phase2TaskSpecs := make([]monitorDesktopTaskSpec, 0) if executionOwner == "desktop_tasks" { phase2TaskSpecs, err = s.loadMonitorDesktopTaskSpecs( ctx, tx, actor.TenantID, workspaceID, todayTime, questionIDs, platformIDs, *clientID, actor.UserID, ) if err != nil { return nil, err } } abortedQueuedCount := int64(0) if options.Preempt { for _, targetClientID := range dispatchTargetClientIDs { deferredCount, deferErr := s.deferQueuedNormalTasks( ctx, tx, actor.TenantID, todayTime, brand.ID, configuredQuestionIDs(configuredQuestions), monitoringPlatformIDs(platforms), targetClientID, requestID, ttlExpiresAt, ) if deferErr != nil { return nil, deferErr } abortedQueuedCount += deferredCount } } requestScopeJSON, err := json.Marshal(map[string]any{ "brand_id": brand.ID, "keyword_id": keywordID, "question_ids": questionIDs, "platform_ids": platformIDs, "target_client_ids": uuidStrings(dispatchTargetClientIDs), "preempt": options.Preempt, }) if err != nil { return nil, response.ErrInternal(50041, "payload_encode_failed", "failed to encode collect-now request scope") } affectedTaskCount := refreshedCount + createdCount + leasedCount message := "已提升为高优先级并通知桌面端优先执行" if limitApplied && affectedTaskCount > 0 { message = fmt.Sprintf("%s(本次最多执行 %d 个问题)", message, monitoringCollectNowQuestionLimit) } if _, err := tx.Exec(ctx, ` INSERT INTO monitoring_collect_requests ( request_id, tenant_id, workspace_id, brand_id, keyword_id, requested_by_user_id, target_client_id, scope_hash, request_scope, status, requested_task_count, dispatched_task_count, promoted_task_count, aborted_queued_count, interrupt_requested_count, interrupt_generation, message, ttl_expires_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, 'accepted', $10, 0, $11, $12, $13, $14, $15, $16 ) `, requestID, actor.TenantID, workspaceID, brand.ID, nullableInt64(keywordID), actor.UserID, *clientID, scopeHash, requestScopeJSON, affectedTaskCount, affectedTaskCount, abortedQueuedCount, int64(0), interruptGeneration, message, ttlExpiresAt); err != nil { return nil, response.ErrInternal(50041, "request_create_failed", "failed to persist monitoring collect-now request") } if err := s.enqueueCollectNowOutbox( ctx, tx, requestID, workspaceID, dispatchTargetClientIDs, affectedTaskCount, interruptGeneration, ); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50041, "commit_failed", "failed to commit monitoring collect-now") } phase2PublishedTasks := make([]*repository.DesktopTask, 0) phase2DispatchReady := executionOwner == "desktop_tasks" if len(phase2TaskSpecs) > 0 { phase2TaskCount := len(phase2TaskSpecs) publishedTasks, deferredTaskIDs, dispatchErr := s.dispatchMonitorDesktopTasks(ctx, phase2TaskSpecs) if dispatchErr != nil { phase2DispatchReady = false phase2TaskSpecs = nil if s.logger != nil { s.logger.Warn("phase2 monitor desktop dispatch failed; pending tasks will retry when a desktop client is online", zap.Error(dispatchErr), zap.Int("phase2_task_count", phase2TaskCount), ) } } else { if len(deferredTaskIDs) > 0 && s.logger != nil { s.logger.Info("phase2 monitor desktop tasks deferred until a desktop client is online", zap.Int("deferred_task_count", len(deferredTaskIDs)), ) } phase2PublishedTasks = publishedTasks } } phase2DeferredTasks := make([]*repository.DesktopTask, 0) if options.Preempt && phase2DispatchReady { excludedMonitorTaskIDs := make([]int64, 0, len(phase2TaskSpecs)) for _, spec := range phase2TaskSpecs { excludedMonitorTaskIDs = append(excludedMonitorTaskIDs, spec.MonitorTaskID) } phase2TargetClientIDs := desktopTaskClientIDs(phase2PublishedTasks) if len(phase2TargetClientIDs) == 0 { phase2TargetClientIDs = []uuid.UUID{*clientID} } for _, phase2TargetClientID := range phase2TargetClientIDs { deferredTasks, deferErr := s.deferQueuedPhase2MonitorTasks(ctx, phase2TargetClientID, excludedMonitorTaskIDs) if deferErr != nil { return nil, deferErr } phase2DeferredTasks = append(phase2DeferredTasks, deferredTasks...) } if len(phase2DeferredTasks) > 0 { abortedQueuedCount += int64(len(phase2DeferredTasks)) if _, updateErr := s.monitoringPool.Exec(ctx, ` UPDATE monitoring_collect_requests SET aborted_queued_count = aborted_queued_count + $2, updated_at = NOW() WHERE request_id = $1 `, requestID, len(phase2DeferredTasks)); updateErr != nil { return nil, response.ErrInternal(50041, "request_update_failed", "failed to persist phase2 deferred queue count") } } } for _, task := range phase2PublishedTasks { if publishErr := publishMonitorDesktopTaskAvailable( context.Background(), task.TargetClientID.String(), task, func(publishCtx context.Context, event DesktopTaskEvent) error { return publishDesktopTaskEvent(publishCtx, s.rabbitMQ, event) }, func(publishCtx context.Context, event stream.DesktopDispatchEvent) error { return publishDesktopDispatchEvent(publishCtx, s.rabbitMQ, s.logger, event) }, ); publishErr != nil && s.logger != nil { s.logger.Warn("phase2 monitor desktop task available publish failed") } } for _, task := range phase2DeferredTasks { if publishErr := publishMonitorDesktopTaskAvailable( context.Background(), task.TargetClientID.String(), task, func(publishCtx context.Context, event DesktopTaskEvent) error { return publishDesktopTaskEvent(publishCtx, s.rabbitMQ, event) }, func(publishCtx context.Context, event stream.DesktopDispatchEvent) error { return publishDesktopDispatchEvent(publishCtx, s.rabbitMQ, s.logger, event) }, ); publishErr != nil && s.logger != nil { s.logger.Warn("phase2 deferred monitor desktop task publish failed", zap.String("task_id", task.DesktopID.String()), zap.Error(publishErr), ) } } hasEffectiveSnapshot := leasedCount > 0 || completedCount > 0 responseData := &MonitoringCollectNowResponse{ CollectionMode: quota.CollectionMode, RefreshedTaskCount: refreshedCount, CreatedTaskCount: createdCount, LeasedTaskCount: leasedCount, CompletedTaskCount: completedCount, HasEffectiveSnapshot: hasEffectiveSnapshot, RequestID: requestID.String(), TargetClientID: clientID.String(), AffectedTaskCount: affectedTaskCount, PromotedTaskCount: affectedTaskCount, AbortedQueuedCount: abortedQueuedCount, InterruptRequestedCount: 0, InterruptGeneration: interruptGeneration, TTLExpiresAt: ttlExpiresAt.Format(time.RFC3339), Message: message, } return s.finalizeCollectNowResponse(ctx, requestID, quota.CollectionMode, options.WaitForFirstDispatch, responseData), nil } func (s *MonitoringService) resolveCollectNowClientID( ctx context.Context, tenantID, workspaceID, userID int64, defaultClientID *uuid.UUID, preferredClientID *uuid.UUID, platformIDs []string, ) (*uuid.UUID, error) { if preferredClientID != nil { if err := s.ensureClientBelongsToUser(ctx, tenantID, workspaceID, userID, *preferredClientID, platformIDs); err != nil { return nil, err } return preferredClientID, nil } clientID, err := s.findRandomOnlineClientForUserWithMonitoringPlatforms(ctx, tenantID, workspaceID, userID, platformIDs) if err != nil { return nil, err } if clientID != nil { return clientID, nil } if defaultClientID == nil { return nil, nil } if err := s.ensureClientBelongsToUser(ctx, tenantID, workspaceID, userID, *defaultClientID, platformIDs); err != nil { return nil, err } return defaultClientID, nil } func (s *MonitoringService) hasOnlineClientForUser(ctx context.Context, tenantID, workspaceID, userID int64) (bool, error) { clientID, err := s.findLatestOnlineClientForUser(ctx, tenantID, workspaceID, userID) if err != nil { return false, err } return clientID != nil, nil } func (s *MonitoringService) findRandomOnlineClientForUser( ctx context.Context, tenantID, workspaceID, userID int64, ) (*uuid.UUID, error) { candidates, err := s.loadOnlineClientIDsForUser(ctx, tenantID, workspaceID, userID) if err != nil { return nil, err } if len(candidates) == 0 { return nil, nil } return randomUUIDFromSlice(candidates), nil } func (s *MonitoringService) findRandomOnlineClientForUserWithMonitoringPlatforms( ctx context.Context, tenantID, workspaceID, userID int64, platformIDs []string, ) (*uuid.UUID, error) { targets, err := s.loadRandomOnlineMonitoringClientIDsByPlatformForUser(ctx, tenantID, workspaceID, userID, platformIDs) if err != nil { return nil, err } return randomUUIDFromSlice(collectNowDispatchTargetClientIDs(targets, nil)), nil } func (s *MonitoringService) loadOnlineMonitoringPlatformIDsForUser( ctx context.Context, tenantID, workspaceID, userID int64, platformIDs []string, ) ([]string, error) { onlineClientIDs, err := s.loadOnlineClientIDsForUser(ctx, tenantID, workspaceID, userID) if err != nil { return nil, err } if len(onlineClientIDs) == 0 { return nil, nil } return s.loadMonitoringPlatformIDsForClients(ctx, tenantID, workspaceID, userID, onlineClientIDs, platformIDs) } func (s *MonitoringService) loadRandomOnlineMonitoringClientIDsByPlatformForUser( ctx context.Context, tenantID, workspaceID, userID int64, platformIDs []string, ) (map[string]uuid.UUID, error) { platformIDs = reconcileEnabledMonitoringPlatforms(platformIDs) if len(platformIDs) == 0 { platformIDs = monitoringPlatformIDs(defaultMonitoringPlatforms) } onlineClientIDs, err := s.loadOnlineClientIDsForUser(ctx, tenantID, workspaceID, userID) if err != nil { return nil, err } if len(onlineClientIDs) == 0 { return nil, nil } candidatesByPlatform, err := s.loadOnlineMonitoringClientIDCandidatesByPlatformForUser(ctx, tenantID, workspaceID, userID, onlineClientIDs, platformIDs) if err != nil { return nil, err } result := make(map[string]uuid.UUID, len(candidatesByPlatform)) for platformID, candidates := range candidatesByPlatform { if selected := randomUUIDFromSlice(candidates); selected != nil { result[platformID] = *selected } } return result, nil } func (s *MonitoringService) ensureClientBelongsToUser( ctx context.Context, tenantID, workspaceID, userID int64, clientID uuid.UUID, platformIDs []string, ) error { var exists bool if err := s.businessPool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM desktop_clients dc WHERE dc.id = $1 AND dc.tenant_id = $2 AND dc.workspace_id = $3 AND dc.user_id = $4 AND dc.revoked_at IS NULL AND dc.last_seen_at IS NOT NULL AND dc.last_seen_at >= NOW() - $5::interval AND EXISTS ( SELECT 1 FROM platform_accounts pa WHERE pa.tenant_id = dc.tenant_id AND pa.workspace_id = dc.workspace_id AND pa.user_id = dc.user_id AND pa.client_id = dc.id AND pa.deleted_at IS NULL AND pa.platform_id = ANY($6::text[]) ) ) `, clientID, tenantID, workspaceID, userID, formatPgInterval(desktopClientPresenceTTL), reconcileEnabledMonitoringPlatforms(platformIDs)).Scan(&exists); err != nil { return response.ErrInternal(50041, "query_failed", "failed to validate target monitoring desktop client") } if !exists { return response.ErrConflict(40941, "desktop_client_offline", "current user's monitoring desktop client is offline") } return nil } func (s *MonitoringService) nextCollectNowInterruptGeneration( ctx context.Context, tx pgx.Tx, clientIDs []uuid.UUID, executionOwner string, ) (int, error) { var next int clientIDs = uniqueUUIDs(clientIDs) if len(clientIDs) == 0 { return 1, nil } if strings.TrimSpace(executionOwner) == "desktop_tasks" && s.businessPool != nil { if err := s.businessPool.QueryRow(ctx, ` SELECT COALESCE(MAX(interrupt_generation), 0) + 1 FROM desktop_tasks WHERE kind = 'monitor' AND target_client_id = ANY($1::uuid[]) `, clientIDs).Scan(&next); err != nil { return 0, response.ErrInternal(50041, "query_failed", "failed to allocate collect-now interrupt generation") } } else { if err := tx.QueryRow(ctx, ` SELECT COALESCE(MAX(interrupt_generation), 0) + 1 FROM monitoring_collect_tasks WHERE target_client_id = ANY($1::uuid[]) `, clientIDs).Scan(&next); err != nil { return 0, response.ErrInternal(50041, "query_failed", "failed to allocate collect-now interrupt generation") } } if next <= 0 { next = 1 } return next, nil } func monitoringPlatformIDs(items []monitoringPlatformMetadata) []string { if len(items) == 0 { return nil } result := make([]string, 0, len(items)) for _, item := range items { if strings.TrimSpace(item.ID) == "" { continue } result = append(result, item.ID) } return result } func filterMonitoringPlatformsByIDSet(items []monitoringPlatformMetadata, platformIDs []string) []monitoringPlatformMetadata { if len(items) == 0 || len(platformIDs) == 0 { return nil } enabled := make(map[string]struct{}, len(platformIDs)) for _, platformID := range platformIDs { platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } enabled[platformID] = struct{}{} } if len(enabled) == 0 { return nil } result := make([]monitoringPlatformMetadata, 0, len(items)) for _, item := range items { platformID := normalizeMonitoringPlatformID(item.ID) if _, ok := enabled[platformID]; !ok { continue } item.ID = platformID result = append(result, item) } return result } func defaultMonitoringPlatformMetadata() []monitoringPlatformMetadata { return append([]monitoringPlatformMetadata(nil), defaultMonitoringPlatforms...) } func normalizeMonitoringPlatformID(platformID string) string { return strings.ToLower(strings.TrimSpace(platformID)) } func normalizedOptionalMonitoringPlatformID(value *string) string { if value == nil { return "" } return normalizeMonitoringPlatformID(*value) } func normalizedOptionalMonitoringPlatformPointer(value *string) *string { normalized := normalizedOptionalMonitoringPlatformID(value) if normalized == "" { return nil } return &normalized } func monitoringPlatformQueryIDs(value *string) []string { platformID := normalizedOptionalMonitoringPlatformID(value) if platformID == "" { return nil } return []string{platformID} } func reconcileEnabledMonitoringPlatforms(enabled []string) []string { if len(enabled) == 0 { return nil } requested := make(map[string]struct{}, len(enabled)) for _, item := range enabled { id := normalizeMonitoringPlatformID(item) if id == "" { continue } if _, supported := monitoringPlatformNames[id]; !supported { continue } requested[id] = struct{}{} } if len(requested) == 0 { return nil } result := make([]string, 0, len(requested)) for _, platform := range defaultMonitoringPlatforms { if _, ok := requested[platform.ID]; ok { result = append(result, platform.ID) } } return result } func monitoringPlatformSortIndex(platformID string) int { normalized := normalizeMonitoringPlatformID(platformID) for index, item := range defaultMonitoringPlatforms { if item.ID == normalized { return index } } return len(defaultMonitoringPlatforms) } func hashMonitoringCollectNowScope( userID int64, brandID int64, keywordID *int64, questionIDs []int64, platformIDs []string, ) string { payload := map[string]any{ "user_id": userID, "brand_id": brandID, "keyword_id": keywordID, "question_ids": questionIDs, "platform_ids": platformIDs, } body, _ := json.Marshal(payload) sum := md5.Sum(body) return hex.EncodeToString(sum[:]) } func (s *MonitoringService) findLatestOnlineClientForUser(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) { var clientID uuid.UUID err := s.businessPool.QueryRow(ctx, ` SELECT id FROM desktop_clients WHERE tenant_id = $1 AND workspace_id = $2 AND user_id = $3 AND revoked_at IS NULL AND last_seen_at IS NOT NULL AND last_seen_at >= NOW() - $4::interval ORDER BY last_seen_at DESC, created_at DESC, id DESC LIMIT 1 `, tenantID, workspaceID, userID, formatPgInterval(desktopClientPresenceTTL)).Scan(&clientID) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, response.ErrInternal(50041, "query_failed", "failed to inspect online monitoring desktop client") } return &clientID, nil } func formatPgInterval(duration time.Duration) string { return fmt.Sprintf("%d seconds", int64(duration/time.Second)) } func (s *MonitoringService) resolveBrand(ctx context.Context, tenantID, brandID int64) (*monitoringBrandIdentity, error) { targetBrandID := brandID if targetBrandID == 0 { if err := s.businessPool.QueryRow(ctx, ` SELECT id FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1 `, tenantID).Scan(&targetBrandID); err != nil { if err == pgx.ErrNoRows { return nil, response.ErrNotFound(40441, "brand_not_found", "no brand found for tenant") } return nil, response.ErrInternal(50041, "query_failed", "failed to resolve brand") } } brand := &monitoringBrandIdentity{} if err := s.businessPool.QueryRow(ctx, ` SELECT id, name FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, targetBrandID, tenantID).Scan(&brand.ID, &brand.Name); err != nil { if err == pgx.ErrNoRows { return nil, response.ErrNotFound(40441, "brand_not_found", "brand not found") } return nil, response.ErrInternal(50041, "query_failed", "failed to resolve brand") } return brand, nil } func (s *MonitoringService) ensureKeywordBelongsToBrand(ctx context.Context, tenantID, brandID, keywordID int64) error { var exists bool if err := s.businessPool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM brand_keywords WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL ) `, keywordID, tenantID, brandID).Scan(&exists); err != nil { return response.ErrInternal(50041, "query_failed", "failed to validate keyword") } if !exists { return response.ErrBadRequest(40041, "invalid_keyword", "keyword does not belong to the selected brand") } return nil } func (s *MonitoringService) loadConfiguredQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]monitoringConfiguredQuestion, error) { rows, err := s.businessPool.Query(ctx, ` SELECT q.id, q.keyword_id, q.question_text FROM brand_questions q JOIN brand_keywords k ON k.id = q.keyword_id AND k.tenant_id = q.tenant_id AND k.brand_id = q.brand_id AND k.deleted_at IS NULL AND k.status = 'active' WHERE q.tenant_id = $1 AND q.brand_id = $2 AND q.deleted_at IS NULL AND q.status = 'active' AND ($3::bigint IS NULL OR q.keyword_id = $3) ORDER BY q.keyword_id ASC, q.id ASC `, tenantID, brandID, nullableInt64(keywordID)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load configured monitoring questions") } defer rows.Close() items := make([]monitoringConfiguredQuestion, 0) for rows.Next() { var item monitoringConfiguredQuestion if scanErr := rows.Scan(&item.ID, &item.KeywordID, &item.QuestionText); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse configured monitoring questions") } item.QuestionText = strings.TrimSpace(item.QuestionText) if item.QuestionText == "" { return nil, response.ErrInternal(50041, "missing_question_text_snapshot", "configured monitoring question text is empty") } item.QuestionHash = seededQuestionHash(item.QuestionText) items = append(items, item) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate configured monitoring questions") } return items, nil } func configuredQuestionIDs(questions []monitoringConfiguredQuestion) []int64 { ids := make([]int64, 0, len(questions)) for _, item := range questions { ids = append(ids, item.ID) } return ids } func filterConfiguredQuestionsByQuestionID(questions []monitoringConfiguredQuestion, questionID *int64) ([]monitoringConfiguredQuestion, error) { if questionID == nil { return questions, nil } if *questionID <= 0 { return nil, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a positive number") } for _, question := range questions { if question.ID == *questionID { return []monitoringConfiguredQuestion{question}, nil } } return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set") } func normalizeMonitoringPageRequest(req MonitoringPageRequest, defaultPageSize int) MonitoringPageRequest { if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = defaultPageSize } if req.PageSize > maxMonitoringListPageSize { req.PageSize = maxMonitoringListPageSize } req.Query = strings.TrimSpace(req.Query) return req } func emptyMonitoringPage(req MonitoringPageRequest) MonitoringPage { req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize) return MonitoringPage{ Page: req.Page, PageSize: req.PageSize, Total: 0, } } func monitoringPageFromRequest(req MonitoringPageRequest, total int64) MonitoringPage { req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize) return MonitoringPage{ Page: req.Page, PageSize: req.PageSize, Total: total, } } func monitoringPageOffset(req MonitoringPageRequest) int { req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize) return (req.Page - 1) * req.PageSize } func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error { if pruneMissing { if len(questions) == 0 { if _, err := tx.Exec(ctx, ` UPDATE monitoring_question_config_snapshots SET deleted_at = COALESCE(deleted_at, NOW()), updated_at = NOW() WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL `, tenantID, brandID); err != nil { return response.ErrInternal(50041, "snapshot_prune_failed", "failed to prune monitoring question snapshots") } } else if _, err := tx.Exec(ctx, ` UPDATE monitoring_question_config_snapshots SET deleted_at = COALESCE(deleted_at, NOW()), updated_at = NOW() WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL AND NOT (question_id = ANY($3)) `, tenantID, brandID, configuredQuestionIDs(questions)); err != nil { return response.ErrInternal(50041, "snapshot_prune_failed", "failed to prune monitoring question snapshots") } } for _, question := range questions { questionText, textErr := monitoringQuestionTextSnapshot(question) if textErr != nil { return textErr } if _, err := tx.Exec(ctx, ` UPDATE monitoring_question_config_snapshots SET superseded_at = COALESCE(superseded_at, NOW()), deleted_at = NULL, updated_at = NOW() WHERE tenant_id = $1 AND brand_id = $2 AND question_id = $3 AND question_hash <> $4 AND superseded_at IS NULL `, tenantID, brandID, question.ID, question.QuestionHash); err != nil { return response.ErrInternal(50041, "snapshot_update_failed", "failed to supersede monitoring question snapshot") } if _, err := tx.Exec(ctx, ` INSERT INTO monitoring_question_config_snapshots ( tenant_id, brand_id, question_id, keyword_id, question_hash, question_text_snapshot, monitor_enabled, projected_at ) VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW()) ON CONFLICT (tenant_id, question_id, question_hash) DO UPDATE SET brand_id = EXCLUDED.brand_id, keyword_id = EXCLUDED.keyword_id, question_text_snapshot = EXCLUDED.question_text_snapshot, monitor_enabled = TRUE, superseded_at = NULL, deleted_at = NULL, projected_at = NOW(), updated_at = NOW() `, tenantID, brandID, question.ID, question.KeywordID, question.QuestionHash, questionText); err != nil { return response.ErrInternal(50041, "snapshot_upsert_failed", "failed to persist monitoring question snapshot") } } return nil } func (s *MonitoringService) ensureCollectNowTasks( ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, platforms []monitoringPlatformMetadata, businessDate time.Time, targetClientIDsByPlatform map[string]uuid.UUID, targetClientID uuid.UUID, interruptGeneration int, executionOwner string, ) (int64, int64, int64, int64, error) { const runMode = "plugin_standard" if strings.TrimSpace(executionOwner) == "" { executionOwner = "legacy" } dateText := businessDate.Format("2006-01-02") var refreshedCount int64 var createdCount int64 for _, question := range questions { questionText, textErr := monitoringQuestionTextSnapshot(question) if textErr != nil { return 0, 0, 0, 0, textErr } for _, platform := range platforms { platformID := normalizeMonitoringPlatformID(platform.ID) if platformID == "" { continue } platformTargetClientID, ok := targetClientIDsByPlatform[platformID] if !ok || platformTargetClientID == uuid.Nil { platformTargetClientID = targetClientID } tag, err := tx.Exec(ctx, ` UPDATE monitoring_collect_tasks SET question_hash = $7, question_text_snapshot = $12, status = 'pending', planned_at = NOW(), trigger_source = 'manual', dispatch_priority = 5000, dispatch_lane = 'high', target_client_id = $9, dispatch_after = NOW(), interrupt_generation = $10, superseded_by_request_id = NULL, last_dispatched_at = NULL, execution_owner = $11, ingest_shard_key = COALESCE( NULLIF(ingest_shard_key, ''), tenant_id::text || ':' || brand_id::text || ':' || business_date::text ), lease_token_hash = NULL, leased_to_executor = NULL, leased_at = NULL, lease_expires_at = NULL, callback_received_at = NULL, completed_at = NULL, skip_reason = NULL, request_payload_json = NULL, error_message = NULL, updated_at = NOW() WHERE tenant_id = $1 AND brand_id = $2 AND question_id = $3 AND ai_platform_id = $4 AND collector_type = $5 AND run_mode = $6 AND business_date = $8::date AND status IN ('pending', 'expired', 'failed', 'completed', 'skipped') `, tenantID, brandID, question.ID, platformID, monitoringCollectorType, runMode, question.QuestionHash, dateText, platformTargetClientID, interruptGeneration, executionOwner, questionText) if err != nil { return 0, 0, 0, 0, response.ErrInternal(50041, "update_failed", "failed to refresh monitoring tasks") } refreshedCount += tag.RowsAffected() tag, err = tx.Exec(ctx, ` UPDATE monitoring_collect_tasks SET question_hash = $7, question_text_snapshot = $11, trigger_source = 'manual', dispatch_priority = 5000, dispatch_lane = 'high', target_client_id = $9, dispatch_after = NOW(), interrupt_generation = $10, superseded_by_request_id = NULL, last_dispatched_at = NULL, execution_owner = COALESCE(execution_owner, 'legacy'), ingest_shard_key = COALESCE( NULLIF(ingest_shard_key, ''), tenant_id::text || ':' || brand_id::text || ':' || business_date::text ), updated_at = NOW() WHERE tenant_id = $1 AND brand_id = $2 AND question_id = $3 AND ai_platform_id = $4 AND collector_type = $5 AND run_mode = $6 AND business_date = $8::date AND status = 'leased' AND COALESCE(execution_owner, 'legacy') = 'legacy' `, tenantID, brandID, question.ID, platformID, monitoringCollectorType, runMode, question.QuestionHash, dateText, platformTargetClientID, interruptGeneration, questionText) if err != nil { return 0, 0, 0, 0, response.ErrInternal(50041, "update_failed", "failed to promote leased monitoring tasks") } tag, err = tx.Exec(ctx, ` INSERT INTO monitoring_collect_tasks ( tenant_id, brand_id, question_id, question_hash, question_text_snapshot, ai_platform_id, collector_type, trigger_source, run_mode, business_date, planned_at, status, dispatch_priority, dispatch_lane, target_client_id, dispatch_after, interrupt_generation, ingest_shard_key, execution_owner ) VALUES ( $1, $2, $3, $4, $13, $5, $6, 'manual', $7, $8::date, NOW(), 'pending', 5000, 'high', $9, NOW(), $10, $11, $12 ) ON CONFLICT ( tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date ) DO NOTHING `, tenantID, brandID, question.ID, question.QuestionHash, platformID, monitoringCollectorType, runMode, dateText, platformTargetClientID, interruptGeneration, fmt.Sprintf("%d:%d:%s", tenantID, brandID, dateText), executionOwner, questionText) if err != nil { return 0, 0, 0, 0, response.ErrInternal(50041, "insert_failed", "failed to create monitoring tasks") } createdCount += tag.RowsAffected() } } var leasedCount int64 var completedCount int64 if err := tx.QueryRow(ctx, ` SELECT COUNT(*) FILTER (WHERE status = 'leased') AS leased_count, COUNT(*) FILTER (WHERE status = 'completed') AS completed_count FROM monitoring_collect_tasks WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND business_date = $4::date AND question_id = ANY($5) `, tenantID, brandID, monitoringCollectorType, dateText, configuredQuestionIDs(questions)).Scan(&leasedCount, &completedCount); err != nil { return 0, 0, 0, 0, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring tasks") } return refreshedCount, createdCount, leasedCount, completedCount, nil } func (s *MonitoringService) deferQueuedNormalTasks( ctx context.Context, tx pgx.Tx, tenantID int64, businessDate time.Time, brandID int64, questionIDs []int64, platformIDs []string, targetClientID uuid.UUID, requestID uuid.UUID, ttlExpiresAt time.Time, ) (int64, error) { if tx == nil || len(questionIDs) == 0 || len(platformIDs) == 0 { return 0, nil } tag, err := tx.Exec(ctx, ` UPDATE monitoring_collect_tasks SET dispatch_after = GREATEST(COALESCE(dispatch_after, NOW()), $6), superseded_by_request_id = $5, updated_at = NOW() WHERE tenant_id = $1 AND collector_type = $2 AND business_date = $3::date AND COALESCE(execution_owner, 'legacy') = 'legacy' AND status = 'pending' AND target_client_id = $4 AND dispatch_lane IN ('normal', 'normal_boosted', 'retry') AND NOT ( brand_id = $7 AND question_id = ANY($8::bigint[]) AND ai_platform_id = ANY($9::text[]) ) `, tenantID, monitoringCollectorType, businessDate.Format("2006-01-02"), targetClientID, requestID, ttlExpiresAt, brandID, questionIDs, platformIDs) if err != nil { return 0, response.ErrInternal(50041, "update_failed", "failed to defer queued normal monitoring tasks") } return tag.RowsAffected(), nil } func (s *MonitoringService) loadQuota(ctx context.Context, actor auth.Actor, workspaceID int64) (monitoringQuotaConfig, error) { cfg := monitoringQuotaConfig{ CollectionMode: monitoringCollectorType, } clientID, err := s.findLatestOnlineClientWithMonitoringAccountForUser(ctx, actor.TenantID, workspaceID, actor.UserID) if err != nil { return cfg, err } if clientID == nil { clientID, err = s.findLatestRegisteredClientWithMonitoringAccountForUser(ctx, actor.TenantID, workspaceID, actor.UserID) } if err != nil { return cfg, err } if clientID == nil { clientID, err = s.findLatestRegisteredClientForUser(ctx, actor.TenantID, workspaceID, actor.UserID) if err != nil { return cfg, err } } cfg.PrimaryClientID = clientID if clientID == nil { return cfg, nil } platforms, err := s.loadMonitoringPlatformIDsForUser(ctx, actor.TenantID, workspaceID, actor.UserID) if err != nil { return cfg, err } cfg.EnabledPlatforms = platforms return cfg, nil } // findLatestOnlineClientWithMonitoringAccountForUser returns an online client for // dashboard runtime defaults. Collect-now may still randomize among all online // clients that can execute the requested platforms. func (s *MonitoringService) findLatestOnlineClientWithMonitoringAccountForUser(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) { clientIDs, err := s.loadOnlineClientIDsForUser(ctx, tenantID, workspaceID, userID) if err != nil { return nil, err } if len(clientIDs) == 0 { return nil, nil } return &clientIDs[0], nil } // findLatestRegisteredClientWithMonitoringAccountForUser returns the newest // non-revoked desktop client that has at least one bound monitoring platform // account. Platform account health is not authorization. func (s *MonitoringService) findLatestRegisteredClientWithMonitoringAccountForUser(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) { var clientID uuid.UUID err := s.businessPool.QueryRow(ctx, ` SELECT dc.id FROM desktop_clients dc WHERE dc.tenant_id = $1 AND dc.workspace_id = $2 AND dc.user_id = $3 AND dc.revoked_at IS NULL AND EXISTS ( SELECT 1 FROM platform_accounts pa WHERE pa.tenant_id = dc.tenant_id AND pa.workspace_id = dc.workspace_id AND pa.client_id = dc.id AND pa.deleted_at IS NULL AND pa.platform_id = ANY($4::text[]) ) ORDER BY dc.created_at DESC, dc.id DESC LIMIT 1 `, tenantID, workspaceID, userID, monitoringPlatformIDs(defaultMonitoringPlatforms)).Scan(&clientID) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client authorization") } return &clientID, nil } // findLatestRegisteredClientForUser returns the newest non-revoked desktop client // registered by creation order. It intentionally ignores last_seen_at and health; // use findLatestOnlineClientForUser when online presence is required. func (s *MonitoringService) findLatestRegisteredClientForUser(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) { var clientID uuid.UUID err := s.businessPool.QueryRow(ctx, ` SELECT id FROM desktop_clients WHERE tenant_id = $1 AND workspace_id = $2 AND user_id = $3 AND revoked_at IS NULL ORDER BY created_at DESC, id DESC LIMIT 1 `, tenantID, workspaceID, userID).Scan(&clientID) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client") } return &clientID, nil } func (s *MonitoringService) loadMonitoringPlatformIDsForUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]string, error) { rows, err := s.businessPool.Query(ctx, ` SELECT DISTINCT pa.platform_id FROM platform_accounts pa JOIN desktop_clients dc ON dc.id = pa.client_id AND dc.tenant_id = pa.tenant_id AND dc.workspace_id = pa.workspace_id WHERE pa.tenant_id = $1 AND pa.workspace_id = $2 AND pa.user_id = $3 AND pa.deleted_at IS NULL AND pa.platform_id = ANY($4::text[]) AND dc.user_id = $3 AND dc.revoked_at IS NULL `, tenantID, workspaceID, userID, monitoringPlatformIDs(defaultMonitoringPlatforms)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring desktop platform accounts") } defer rows.Close() platformIDs := make([]string, 0) for rows.Next() { var platformID string if scanErr := rows.Scan(&platformID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring desktop platform accounts") } platformIDs = append(platformIDs, platformID) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring desktop platform accounts") } return reconcileEnabledMonitoringPlatforms(platformIDs), nil } func (s *MonitoringService) loadMonitoringClientPlatformIDs(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) ([]string, error) { rows, err := s.businessPool.Query(ctx, ` SELECT DISTINCT platform_id FROM platform_accounts WHERE tenant_id = $1 AND workspace_id = $2 AND client_id = $3 AND deleted_at IS NULL AND platform_id = ANY($4::text[]) `, tenantID, workspaceID, clientID, monitoringPlatformIDs(defaultMonitoringPlatforms)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring desktop platform accounts") } defer rows.Close() platformIDs := make([]string, 0) for rows.Next() { var platformID string if scanErr := rows.Scan(&platformID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring desktop platform accounts") } platformIDs = append(platformIDs, platformID) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring desktop platform accounts") } return reconcileEnabledMonitoringPlatforms(platformIDs), nil } func (s *MonitoringService) loadMonitoringPlatformIDsForClients( ctx context.Context, tenantID, workspaceID, userID int64, clientIDs []uuid.UUID, platformIDs []string, ) ([]string, error) { clientIDs = uniqueUUIDs(clientIDs) if len(clientIDs) == 0 { return nil, nil } platformIDs = reconcileEnabledMonitoringPlatforms(platformIDs) if len(platformIDs) == 0 { platformIDs = monitoringPlatformIDs(defaultMonitoringPlatforms) } rows, err := s.businessPool.Query(ctx, ` SELECT DISTINCT platform_id FROM platform_accounts WHERE tenant_id = $1 AND workspace_id = $2 AND user_id = $3 AND client_id = ANY($4::uuid[]) AND deleted_at IS NULL AND platform_id = ANY($5::text[]) `, tenantID, workspaceID, userID, clientIDs, platformIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load online monitoring desktop platform accounts") } defer rows.Close() result := make([]string, 0) for rows.Next() { var platformID string if scanErr := rows.Scan(&platformID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse online monitoring desktop platform accounts") } result = append(result, platformID) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate online monitoring desktop platform accounts") } return reconcileEnabledMonitoringPlatforms(result), nil } func (s *MonitoringService) loadOnlineMonitoringClientIDCandidatesByPlatformForUser( ctx context.Context, tenantID, workspaceID, userID int64, clientIDs []uuid.UUID, platformIDs []string, ) (map[string][]uuid.UUID, error) { clientIDs = uniqueUUIDs(clientIDs) if len(clientIDs) == 0 { return nil, nil } platformIDs = reconcileEnabledMonitoringPlatforms(platformIDs) if len(platformIDs) == 0 { platformIDs = monitoringPlatformIDs(defaultMonitoringPlatforms) } rows, err := s.businessPool.Query(ctx, ` SELECT DISTINCT platform_id, client_id FROM platform_accounts WHERE tenant_id = $1 AND workspace_id = $2 AND user_id = $3 AND client_id = ANY($4::uuid[]) AND deleted_at IS NULL AND platform_id = ANY($5::text[]) `, tenantID, workspaceID, userID, clientIDs, platformIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load online monitoring desktop platform targets") } defer rows.Close() result := make(map[string][]uuid.UUID) for rows.Next() { var ( platformID string clientID uuid.UUID ) if scanErr := rows.Scan(&platformID, &clientID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse online monitoring desktop platform targets") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" || clientID == uuid.Nil { continue } result[platformID] = append(result[platformID], clientID) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate online monitoring desktop platform targets") } for platformID, candidates := range result { result[platformID] = uniqueUUIDs(candidates) } return result, nil } func (s *MonitoringService) loadOnlineClientIDsForUser( ctx context.Context, tenantID, workspaceID, userID int64, ) ([]uuid.UUID, error) { rows, err := s.businessPool.Query(ctx, ` SELECT dc.id FROM desktop_clients dc WHERE dc.tenant_id = $1 AND dc.workspace_id = $2 AND dc.user_id = $3 AND dc.revoked_at IS NULL AND dc.last_seen_at IS NOT NULL AND dc.last_seen_at >= NOW() - $4::interval ORDER BY dc.last_seen_at DESC, dc.created_at DESC, dc.id DESC `, tenantID, workspaceID, userID, formatPgInterval(desktopClientPresenceTTL)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to inspect online monitoring desktop clients") } defer rows.Close() clientIDs := make([]uuid.UUID, 0) for rows.Next() { var clientID uuid.UUID if scanErr := rows.Scan(&clientID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse online monitoring desktop clients") } clientIDs = append(clientIDs, clientID) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate online monitoring desktop clients") } return clientIDs, nil } func randomUUIDFromSlice(values []uuid.UUID) *uuid.UUID { if len(values) == 0 { return nil } selected := values[randomIndex(len(values))] return &selected } func randomIndex(length int) int { if length <= 1 { return 0 } if value, err := crand.Int(crand.Reader, big.NewInt(int64(length))); err == nil { return int(value.Int64()) } return int(time.Now().UnixNano() % int64(length)) } func platformIDsFromClientTargetMap(targets map[string]uuid.UUID) []string { if len(targets) == 0 { return nil } result := make([]string, 0, len(targets)) for _, platform := range defaultMonitoringPlatforms { if clientID, ok := targets[platform.ID]; ok && clientID != uuid.Nil { result = append(result, platform.ID) } } return result } func collectNowDispatchTargetClientIDs(targets map[string]uuid.UUID, fallback *uuid.UUID) []uuid.UUID { result := make([]uuid.UUID, 0, len(targets)+1) for _, platform := range defaultMonitoringPlatforms { if clientID, ok := targets[platform.ID]; ok && clientID != uuid.Nil { result = append(result, clientID) } } if fallback != nil && *fallback != uuid.Nil { result = append(result, *fallback) } return uniqueUUIDs(result) } func uuidStrings(values []uuid.UUID) []string { values = uniqueUUIDs(values) if len(values) == 0 { return nil } result := make([]string, 0, len(values)) for _, value := range values { if value == uuid.Nil { continue } result = append(result, value.String()) } return result } func desktopTaskClientIDs(tasks []*repository.DesktopTask) []uuid.UUID { if len(tasks) == 0 { return nil } result := make([]uuid.UUID, 0, len(tasks)) for _, task := range tasks { if task == nil || task.TargetClientID == uuid.Nil { continue } result = append(result, task.TargetClientID) } return uniqueUUIDs(result) } func newMonitoringDerivedMetrics() monitoringDerivedMetrics { return monitoringDerivedMetrics{ ByDate: make(map[string]monitoringDerivedRateStats), ByDatePlatform: make(map[string]map[string]monitoringDerivedRateStats), ByDateQuestion: make(map[string]map[int64]monitoringDerivedRateStats), ByQuestion: make(map[int64]monitoringDerivedRateStats), } } func (stats *monitoringDerivedRateStats) add(summary monitoringAnswerParseSummary) { stats.Total++ if summary.BrandMentioned { stats.MentionedCount++ } if summary.BrandMentionPosition == "top1" { stats.Top1Count++ } if summary.FirstRecommended { stats.FirstRecommend++ } if summary.BrandMentioned && summary.SentimentLabel == "positive" { stats.PositiveMentions++ } } func (stats monitoringDerivedRateStats) hasSamples() bool { return stats.Total > 0 } func (stats monitoringDerivedRateStats) mentionRate() *float64 { return divideAsPointer(stats.MentionedCount, stats.Total) } func (stats monitoringDerivedRateStats) top1MentionRate() *float64 { return divideAsPointer(stats.Top1Count, stats.Total) } func (stats monitoringDerivedRateStats) firstRecommendRate() *float64 { return divideAsPointer(stats.FirstRecommend, stats.Total) } func (stats monitoringDerivedRateStats) positiveMentionRate() *float64 { return divideAsPointer(stats.PositiveMentions, stats.Total) } func addMonitoringDerivedParseSample(metrics *monitoringDerivedMetrics, sample monitoringDerivedParseSample) { if metrics == nil { return } dateKey := sample.BusinessDate.Format("2006-01-02") dateStats := metrics.ByDate[dateKey] dateStats.add(sample.Summary) metrics.ByDate[dateKey] = dateStats platformStatsByDate, ok := metrics.ByDatePlatform[dateKey] if !ok { platformStatsByDate = make(map[string]monitoringDerivedRateStats) metrics.ByDatePlatform[dateKey] = platformStatsByDate } platformStats := platformStatsByDate[sample.PlatformID] platformStats.add(sample.Summary) platformStatsByDate[sample.PlatformID] = platformStats questionStatsByDate, ok := metrics.ByDateQuestion[dateKey] if !ok { questionStatsByDate = make(map[int64]monitoringDerivedRateStats) metrics.ByDateQuestion[dateKey] = questionStatsByDate } questionStatsForDate := questionStatsByDate[sample.QuestionID] questionStatsForDate.add(sample.Summary) questionStatsByDate[sample.QuestionID] = questionStatsForDate questionStats := metrics.ByQuestion[sample.QuestionID] questionStats.add(sample.Summary) metrics.ByQuestion[sample.QuestionID] = questionStats } func monitoringDerivedParseSampleIsNewer(candidate, existing monitoringDerivedParseSample) bool { if candidate.EventAt.Valid && existing.EventAt.Valid && !candidate.EventAt.Time.Equal(existing.EventAt.Time) { return candidate.EventAt.Time.After(existing.EventAt.Time) } if candidate.EventAt.Valid != existing.EventAt.Valid { return candidate.EventAt.Valid } return candidate.RunID > existing.RunID } func upsertLatestMonitoringDerivedParseSample(samples map[monitoringDerivedSampleKey]monitoringDerivedParseSample, sample monitoringDerivedParseSample) { if samples == nil { return } key := monitoringDerivedSampleKey{ Date: sample.BusinessDate.Format("2006-01-02"), PlatformID: sample.PlatformID, QuestionID: sample.QuestionID, } if existing, ok := samples[key]; !ok || monitoringDerivedParseSampleIsNewer(sample, existing) { samples[key] = sample } } func buildMonitoringDerivedMetricsFromLatestSamples(samples map[monitoringDerivedSampleKey]monitoringDerivedParseSample) monitoringDerivedMetrics { metrics := newMonitoringDerivedMetrics() for _, sample := range samples { addMonitoringDerivedParseSample(&metrics, sample) } return metrics } func (s *MonitoringService) loadDerivedParseMetrics( ctx context.Context, tenantID, brandID int64, questionIDs []int64, brandName string, startDate, endDate time.Time, aiPlatformID *string, ) (monitoringDerivedMetrics, error) { metrics := newMonitoringDerivedMetrics() if questionIDs != nil && len(questionIDs) == 0 { return metrics, nil } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) rows, err := s.monitoringPool.Query(ctx, ` SELECT r.id, r.business_date, r.ai_platform_id, r.question_id, r.event_at, r.raw_answer_text, p.brand_mentioned, p.brand_mention_position, p.first_recommended, p.sentiment_label, p.matched_brand_terms FROM ( SELECT DISTINCT ON (business_date, question_id, ai_platform_id) id, tenant_id, business_date, ai_platform_id, question_id, raw_answer_text, COALESCE(completed_at, updated_at, created_at) AS event_at FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND status = 'succeeded' AND business_date BETWEEN $4::date AND $5::date AND ($6::bigint[] IS NULL OR question_id = ANY($6)) AND ($7::text[] IS NULL OR ai_platform_id = ANY($7)) ORDER BY business_date, question_id, ai_platform_id, COALESCE(completed_at, updated_at, created_at) DESC NULLS LAST, id DESC ) r LEFT JOIN question_monitor_parse_results p ON p.tenant_id = r.tenant_id AND p.run_id = r.id `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) if err != nil { return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics") } defer rows.Close() samples := make(map[monitoringDerivedSampleKey]monitoringDerivedParseSample) for rows.Next() { var runID int64 var businessDate time.Time var platformID string var questionID int64 var eventAt sql.NullTime var answerText sql.NullString var brandMentioned sql.NullBool var brandMentionPosition sql.NullString var firstRecommended sql.NullBool var sentimentLabel sql.NullString var matchedBrandTermsJSON []byte if scanErr := rows.Scan( &runID, &businessDate, &platformID, &questionID, &eventAt, &answerText, &brandMentioned, &brandMentionPosition, &firstRecommended, &sentimentLabel, &matchedBrandTermsJSON, ); scanErr != nil { return metrics, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring derived metrics") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } summary := resolveMonitoringAnswerParseSummary( answerText.String, brandName, monitoringStoredParseFields{ BrandMentioned: boolPointer(brandMentioned), BrandMentionPosition: stringPointer(brandMentionPosition), FirstRecommended: boolPointer(firstRecommended), SentimentLabel: stringPointer(sentimentLabel), MatchedBrandTerms: decodeMonitoringMatchedBrandTerms(matchedBrandTermsJSON), }, ) upsertLatestMonitoringDerivedParseSample(samples, monitoringDerivedParseSample{ BusinessDate: businessDate, PlatformID: platformID, QuestionID: questionID, EventAt: eventAt, RunID: runID, Summary: summary, }) } if err := rows.Err(); err != nil { return metrics, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring derived metrics") } return buildMonitoringDerivedMetricsFromLatestSamples(samples), nil } func decodeMonitoringMatchedBrandTerms(raw []byte) []string { if len(raw) == 0 { return nil } var items []string if err := json.Unmarshal(raw, &items); err != nil { return nil } return normalizeMonitoringBrandTermList(items) } func (s *MonitoringService) loadOverview( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, collectionMode string, aiPlatformID *string, derived monitoringDerivedMetrics, ) (MonitoringOverview, time.Time, error) { if questionIDs != nil { return s.loadOverviewFromRuns(ctx, tenantID, brandID, questionIDs, startDate, endDate, collectionMode, aiPlatformID, derived) } if platformID := normalizedOptionalMonitoringPlatformID(aiPlatformID); platformID != "" { return s.loadOverviewForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived) } overview := MonitoringOverview{ CollectionMode: collectionMode, ConfidenceLevel: "low", } latestDate := endDate current, found, err := s.loadOverviewSnapshotForDate(ctx, tenantID, brandID, collectionMode, endDate) if err != nil { return overview, latestDate, err } if !found { return overview, latestDate, nil } applyOverviewSnapshot(&overview, current, derived) if current.ActualSampleCount <= 0 { return overview, latestDate, nil } latestDate = current.Date previous, foundPrevious, err := s.loadLatestSampledOverviewSnapshot(ctx, tenantID, brandID, collectionMode, startDate, current.Date.AddDate(0, 0, -1)) if err != nil { return overview, latestDate, err } if !foundPrevious { return overview, latestDate, nil } applyDerivedRatesToOverviewSnapshot(&previous, derived) overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate) overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate) overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate) overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate) return overview, latestDate, nil } func (s *MonitoringService) loadOverviewFromRuns( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, collectionMode string, aiPlatformID *string, derived monitoringDerivedMetrics, ) (MonitoringOverview, time.Time, error) { overview := MonitoringOverview{ CollectionMode: collectionMode, ConfidenceLevel: "low", } latestDate := endDate if len(questionIDs) == 0 { return overview, latestDate, nil } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) current, found, err := s.loadOverviewRunAggregateForDate(ctx, tenantID, brandID, questionIDs, endDate, platformQueryIDs) if err != nil { return overview, latestDate, err } if !found { return overview, latestDate, nil } applyOverviewSnapshot(&overview, current, derived) if current.ActualSampleCount <= 0 { return overview, latestDate, nil } latestDate = current.Date previous, foundPrevious, err := s.loadLatestOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, current.Date.AddDate(0, 0, -1), platformQueryIDs) if err != nil { return overview, latestDate, err } if !foundPrevious { return overview, latestDate, nil } applyDerivedRatesToOverviewSnapshot(&previous, derived) overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate) overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate) overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate) overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate) return overview, latestDate, nil } func (s *MonitoringService) loadOverviewForPlatform( ctx context.Context, tenantID, brandID int64, startDate, endDate time.Time, collectionMode string, aiPlatformID string, derived monitoringDerivedMetrics, ) (MonitoringOverview, time.Time, error) { overview := MonitoringOverview{ CollectionMode: collectionMode, ConfidenceLevel: "low", } latestDate := endDate platformQueryIDs := monitoringPlatformQueryIDs(&aiPlatformID) current, found, err := s.loadPlatformOverviewSnapshotForDate(ctx, tenantID, brandID, platformQueryIDs, collectionMode, endDate) if err != nil { return overview, latestDate, err } if !found { return overview, latestDate, nil } applyOverviewSnapshot(&overview, current, derived) if current.ActualSampleCount <= 0 { return overview, latestDate, nil } latestDate = current.Date previous, foundPrevious, err := s.loadLatestSampledPlatformOverviewSnapshot( ctx, tenantID, brandID, platformQueryIDs, collectionMode, startDate, current.Date.AddDate(0, 0, -1), ) if err != nil { return overview, latestDate, err } if !foundPrevious { return overview, latestDate, nil } applyDerivedRatesToOverviewSnapshot(&previous, derived) overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate) overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate) overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate) overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate) return overview, latestDate, nil } type monitoringOverviewSnapshot struct { Date time.Time DesiredSampleCount int64 PlannedSampleCount int64 ActualSampleCount int64 CoverageRate sql.NullFloat64 ConfidenceLevel string TriggerSource sql.NullString SnapshotUpdatedAt sql.NullTime MentionRate sql.NullFloat64 Top1MentionRate sql.NullFloat64 FirstRecommendRate sql.NullFloat64 PositiveMentionRate sql.NullFloat64 CitationRate sql.NullFloat64 } func (s *MonitoringService) loadOverviewRunAggregateForDate( ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, platformIDs []string, ) (monitoringOverviewSnapshot, bool, error) { return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, businessDate, businessDate, platformIDs, false) } func (s *MonitoringService) loadLatestOverviewRunAggregate( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, platformIDs []string, ) (monitoringOverviewSnapshot, bool, error) { if endDate.Before(startDate) { return monitoringOverviewSnapshot{}, false, nil } return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, endDate, platformIDs, true) } func (s *MonitoringService) loadOverviewRunAggregate( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, platformIDs []string, requireSamples bool, ) (monitoringOverviewSnapshot, bool, error) { var item monitoringOverviewSnapshot if len(questionIDs) == 0 { return item, false, nil } rows, err := s.monitoringPool.Query(ctx, ` SELECT r.business_date, COUNT(*)::bigint AS actual_sample_count, COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count, COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count, COUNT(*) FILTER (WHERE p.first_recommended IS TRUE)::bigint AS first_recommended_count, COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE AND p.sentiment_label = 'positive')::bigint AS positive_mentioned_count, MAX(r.completed_at) AS last_sampled_at FROM ( SELECT DISTINCT ON (business_date, question_id, ai_platform_id) id, tenant_id, business_date, question_id, ai_platform_id, completed_at, updated_at, created_at FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND status = 'succeeded' AND business_date BETWEEN $4::date AND $5::date AND question_id = ANY($6) AND ($7::text[] IS NULL OR ai_platform_id = ANY($7)) ORDER BY business_date, question_id, ai_platform_id, COALESCE(completed_at, updated_at, created_at) DESC NULLS LAST, id DESC ) r LEFT JOIN question_monitor_parse_results p ON p.tenant_id = r.tenant_id AND p.run_id = r.id GROUP BY r.business_date ORDER BY r.business_date DESC `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs)) if err != nil { return item, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview") } defer rows.Close() for rows.Next() { var mentionedCount int64 var top1MentionedCount int64 var firstRecommendedCount int64 var positiveMentionedCount int64 if scanErr := rows.Scan( &item.Date, &item.ActualSampleCount, &mentionedCount, &top1MentionedCount, &firstRecommendedCount, &positiveMentionedCount, &item.SnapshotUpdatedAt, ); scanErr != nil { return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring overview") } if requireSamples && item.ActualSampleCount <= 0 { continue } plannedSampleCount, planErr := s.loadOverviewRunPlannedCount(ctx, tenantID, brandID, questionIDs, item.Date, platformIDs) if planErr != nil { return monitoringOverviewSnapshot{}, false, planErr } item.DesiredSampleCount = plannedSampleCount item.PlannedSampleCount = plannedSampleCount item.CoverageRate = pointerFloat64ToNull(divideAsPointer(item.ActualSampleCount, plannedSampleCount)) item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, plannedSampleCount, floatPointer(item.CoverageRate)) item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount)) item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount)) item.FirstRecommendRate = pointerFloat64ToNull(divideAsPointer(firstRecommendedCount, item.ActualSampleCount)) item.PositiveMentionRate = pointerFloat64ToNull(divideAsPointer(positiveMentionedCount, item.ActualSampleCount)) return item, true, nil } if err := rows.Err(); err != nil { return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring overview") } return monitoringOverviewSnapshot{}, false, nil } func (s *MonitoringService) loadOverviewRunPlannedCount( ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, platformIDs []string, ) (int64, error) { var plannedSampleCount int64 if err := s.monitoringPool.QueryRow(ctx, ` SELECT COUNT(*)::bigint FROM monitoring_collect_tasks WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND business_date = $4::date AND question_id = ANY($5) AND ($6::text[] IS NULL OR ai_platform_id = ANY($6)) AND NOT (status = 'skipped' AND skip_reason = ANY($7::text[])) `, tenantID, brandID, monitoringCollectorType, businessDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs), monitoringProjectionExcludedSkipReasons).Scan(&plannedSampleCount); err != nil { return 0, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview") } if plannedSampleCount > 0 { return plannedSampleCount, nil } if platformIDs != nil { return int64(len(questionIDs) * len(platformIDs)), nil } return int64(len(questionIDs) * len(defaultMonitoringPlatforms)), nil } func (s *MonitoringService) loadOverviewSnapshotForDate(ctx context.Context, tenantID, brandID int64, collectionMode string, businessDate time.Time) (monitoringOverviewSnapshot, bool, error) { var item monitoringOverviewSnapshot err := s.monitoringPool.QueryRow(ctx, ` SELECT business_date, desired_sample_count, planned_sample_count, actual_sample_count, coverage_rate::double precision, confidence_level, trigger_source, snapshot_updated_at, mention_rate::double precision, top1_mention_rate::double precision, first_recommend_rate::double precision, positive_mention_rate::double precision, citation_rate::double precision FROM monitoring_brand_daily WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND business_date = $4::date `, tenantID, brandID, collectionMode, businessDate.Format("2006-01-02")).Scan( &item.Date, &item.DesiredSampleCount, &item.PlannedSampleCount, &item.ActualSampleCount, &item.CoverageRate, &item.ConfidenceLevel, &item.TriggerSource, &item.SnapshotUpdatedAt, &item.MentionRate, &item.Top1MentionRate, &item.FirstRecommendRate, &item.PositiveMentionRate, &item.CitationRate, ) if err != nil { if err == pgx.ErrNoRows { return monitoringOverviewSnapshot{}, false, nil } return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview") } return item, true, nil } func (s *MonitoringService) loadLatestSampledOverviewSnapshot(ctx context.Context, tenantID, brandID int64, collectionMode string, startDate, endDate time.Time) (monitoringOverviewSnapshot, bool, error) { if endDate.Before(startDate) { return monitoringOverviewSnapshot{}, false, nil } var item monitoringOverviewSnapshot err := s.monitoringPool.QueryRow(ctx, ` SELECT business_date, desired_sample_count, planned_sample_count, actual_sample_count, coverage_rate::double precision, confidence_level, trigger_source, snapshot_updated_at, mention_rate::double precision, top1_mention_rate::double precision, first_recommend_rate::double precision, positive_mention_rate::double precision, citation_rate::double precision FROM monitoring_brand_daily WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND business_date BETWEEN $4::date AND $5::date AND actual_sample_count > 0 ORDER BY business_date DESC LIMIT 1 `, tenantID, brandID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan( &item.Date, &item.DesiredSampleCount, &item.PlannedSampleCount, &item.ActualSampleCount, &item.CoverageRate, &item.ConfidenceLevel, &item.TriggerSource, &item.SnapshotUpdatedAt, &item.MentionRate, &item.Top1MentionRate, &item.FirstRecommendRate, &item.PositiveMentionRate, &item.CitationRate, ) if err != nil { if err == pgx.ErrNoRows { return monitoringOverviewSnapshot{}, false, nil } return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview") } return item, true, nil } func (s *MonitoringService) loadPlatformOverviewSnapshotForDate( ctx context.Context, tenantID, brandID int64, aiPlatformIDs []string, collectionMode string, businessDate time.Time, ) (monitoringOverviewSnapshot, bool, error) { var item monitoringOverviewSnapshot err := s.monitoringPool.QueryRow(ctx, ` SELECT business_date, SUM(planned_sample_count) AS planned_sample_count, SUM(actual_sample_count) AS actual_sample_count, CASE WHEN SUM(actual_sample_count) > 0 THEN SUM(COALESCE(mention_rate::double precision, 0) * actual_sample_count)::double precision / SUM(actual_sample_count)::double precision ELSE NULL END AS mention_rate, CASE WHEN SUM(actual_sample_count) > 0 THEN SUM(COALESCE(top1_mention_rate::double precision, 0) * actual_sample_count)::double precision / SUM(actual_sample_count)::double precision ELSE NULL END AS top1_mention_rate, MAX(last_sampled_at) AS last_sampled_at FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2 AND ai_platform_id = ANY($3) AND collector_type = $4 AND business_date = $5::date GROUP BY business_date `, tenantID, brandID, aiPlatformIDs, collectionMode, businessDate.Format("2006-01-02")).Scan( &item.Date, &item.PlannedSampleCount, &item.ActualSampleCount, &item.MentionRate, &item.Top1MentionRate, &item.SnapshotUpdatedAt, ) if err != nil { if err == pgx.ErrNoRows { return monitoringOverviewSnapshot{}, false, nil } return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform overview") } coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount) item.DesiredSampleCount = item.PlannedSampleCount item.CoverageRate = pointerFloat64ToNull(coverageRate) item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, item.PlannedSampleCount, coverageRate) return item, true, nil } func (s *MonitoringService) loadLatestSampledPlatformOverviewSnapshot( ctx context.Context, tenantID, brandID int64, aiPlatformIDs []string, collectionMode string, startDate, endDate time.Time, ) (monitoringOverviewSnapshot, bool, error) { if endDate.Before(startDate) { return monitoringOverviewSnapshot{}, false, nil } var item monitoringOverviewSnapshot err := s.monitoringPool.QueryRow(ctx, ` SELECT business_date, SUM(planned_sample_count) AS planned_sample_count, SUM(actual_sample_count) AS actual_sample_count, CASE WHEN SUM(actual_sample_count) > 0 THEN SUM(COALESCE(mention_rate::double precision, 0) * actual_sample_count)::double precision / SUM(actual_sample_count)::double precision ELSE NULL END AS mention_rate, CASE WHEN SUM(actual_sample_count) > 0 THEN SUM(COALESCE(top1_mention_rate::double precision, 0) * actual_sample_count)::double precision / SUM(actual_sample_count)::double precision ELSE NULL END AS top1_mention_rate, MAX(last_sampled_at) AS last_sampled_at FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2 AND ai_platform_id = ANY($3) AND collector_type = $4 AND business_date BETWEEN $5::date AND $6::date AND actual_sample_count > 0 GROUP BY business_date ORDER BY business_date DESC LIMIT 1 `, tenantID, brandID, aiPlatformIDs, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan( &item.Date, &item.PlannedSampleCount, &item.ActualSampleCount, &item.MentionRate, &item.Top1MentionRate, &item.SnapshotUpdatedAt, ) if err != nil { if err == pgx.ErrNoRows { return monitoringOverviewSnapshot{}, false, nil } return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform overview") } coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount) item.DesiredSampleCount = item.PlannedSampleCount item.CoverageRate = pointerFloat64ToNull(coverageRate) item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, item.PlannedSampleCount, coverageRate) return item, true, nil } func applyOverviewSnapshot(overview *MonitoringOverview, snapshot monitoringOverviewSnapshot, derived monitoringDerivedMetrics) { if overview == nil { return } overview.DesiredSampleCount = snapshot.DesiredSampleCount overview.PlannedSampleCount = snapshot.PlannedSampleCount overview.ActualSampleCount = snapshot.ActualSampleCount overview.CoverageRate = floatPointer(snapshot.CoverageRate) overview.LastTriggerSource = stringPointer(snapshot.TriggerSource) if strings.TrimSpace(snapshot.ConfidenceLevel) != "" { overview.ConfidenceLevel = snapshot.ConfidenceLevel } if snapshot.ActualSampleCount <= 0 { overview.LastSampledAt = nil overview.MentionRate = nil overview.Top1MentionRate = nil overview.FirstRecommendRate = nil overview.PositiveMentionRate = nil overview.CitationRate = nil return } applyDerivedRatesToOverviewSnapshot(&snapshot, derived) overview.LastSampledAt = formatNullTime(snapshot.SnapshotUpdatedAt) overview.MentionRate = floatPointer(snapshot.MentionRate) overview.Top1MentionRate = floatPointer(snapshot.Top1MentionRate) overview.FirstRecommendRate = floatPointer(snapshot.FirstRecommendRate) overview.PositiveMentionRate = floatPointer(snapshot.PositiveMentionRate) overview.CitationRate = floatPointer(snapshot.CitationRate) } func applyDerivedRatesToOverviewSnapshot(snapshot *monitoringOverviewSnapshot, derived monitoringDerivedMetrics) { if snapshot == nil { return } dateKey := snapshot.Date.Format("2006-01-02") if stats, ok := derived.ByDate[dateKey]; ok && stats.hasSamples() { snapshot.MentionRate = pointerFloat64ToNull(stats.mentionRate()) snapshot.Top1MentionRate = pointerFloat64ToNull(stats.top1MentionRate()) snapshot.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate()) snapshot.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate()) } } func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID, workspaceID int64, clientID *uuid.UUID, businessDate time.Time) (map[string]monitoringAccessState, error) { states := map[string]monitoringAccessState{} if clientID == nil { return states, nil } belongs, err := s.monitoringClientBelongsToWorkspace(ctx, tenantID, workspaceID, *clientID) if err != nil { return nil, err } if !belongs { return states, nil } rows, err := s.monitoringPool.Query(ctx, ` SELECT ai_platform_id, access_status FROM monitoring_platform_access_snapshots WHERE tenant_id = $1 AND client_id = $2 AND business_date = $3::date `, tenantID, *clientID, businessDate.Format("2006-01-02")) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load platform access states") } defer rows.Close() for rows.Next() { var platformID string var accessStatus string if scanErr := rows.Scan(&platformID, &accessStatus); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform access states") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } if existing, ok := states[platformID]; ok && strings.TrimSpace(existing.AccessStatus) != "" { continue } states[platformID] = monitoringAccessState{AccessStatus: accessStatus} } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate platform access states") } return states, nil } func (s *MonitoringService) monitoringClientBelongsToWorkspace(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) (bool, error) { var exists bool if err := s.businessPool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM desktop_clients WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND revoked_at IS NULL ) `, clientID, tenantID, workspaceID).Scan(&exists); err != nil { return false, response.ErrInternal(50041, "query_failed", "failed to validate monitoring desktop client workspace") } return exists, nil } func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) { if questionIDs != nil { return s.loadPlatformBreakdownFromRuns(ctx, tenantID, brandID, questionIDs, businessDate, platforms, accessStates, derived) } rows, err := s.monitoringPool.Query(ctx, ` SELECT ai_platform_id, mention_rate::double precision, top1_mention_rate::double precision, platform_sample_status, actual_sample_count, last_sampled_at FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND business_date = $4::date `, tenantID, brandID, collectionMode, businessDate.Format("2006-01-02")) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load platform breakdown") } defer rows.Close() type row struct { MentionRate sql.NullFloat64 Top1MentionRate sql.NullFloat64 PlatformSampleStatus string ActualSampleCount int64 LastSampledAt sql.NullTime } items := make(map[string]row) for rows.Next() { var platformID string var item row if scanErr := rows.Scan( &platformID, &item.MentionRate, &item.Top1MentionRate, &item.PlatformSampleStatus, &item.ActualSampleCount, &item.LastSampledAt, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform breakdown") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } if existing, ok := items[platformID]; ok { totalSamples := existing.ActualSampleCount + item.ActualSampleCount existing.MentionRate = mergeWeightedNullFloat(existing.MentionRate, existing.ActualSampleCount, item.MentionRate, item.ActualSampleCount) existing.Top1MentionRate = mergeWeightedNullFloat(existing.Top1MentionRate, existing.ActualSampleCount, item.Top1MentionRate, item.ActualSampleCount) existing.PlatformSampleStatus = mergeMonitoringSampleStatus(existing.PlatformSampleStatus, item.PlatformSampleStatus) existing.ActualSampleCount = totalSamples existing.LastSampledAt = maxNullTime(existing.LastSampledAt, item.LastSampledAt) items[platformID] = existing continue } items[platformID] = item } dateKey := businessDate.Format("2006-01-02") breakdown := make([]MonitoringPlatformDaily, 0, len(platforms)) for _, platform := range platforms { if item, ok := items[platform.ID]; ok { if platformStats, ok := derived.ByDatePlatform[dateKey][platform.ID]; ok && platformStats.hasSamples() { item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate()) item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate()) } breakdown = append(breakdown, MonitoringPlatformDaily{ AIPlatformID: platform.ID, PlatformName: platform.Name, MentionRate: floatPointer(item.MentionRate), Top1MentionRate: floatPointer(item.Top1MentionRate), PlatformSampleStatus: derivePlatformSampleStatus(item.PlatformSampleStatus, accessStates[platform.ID]), ActualSampleCount: item.ActualSampleCount, LastSampledAt: formatNullTime(item.LastSampledAt), }) continue } breakdown = append(breakdown, MonitoringPlatformDaily{ AIPlatformID: platform.ID, PlatformName: platform.Name, PlatformSampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]), }) } return breakdown, nil } func (s *MonitoringService) loadPlatformBreakdownFromRuns(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) { if len(questionIDs) == 0 { return emptyPlatformBreakdown(platforms, accessStates), nil } dateKey := businessDate.Format("2006-01-02") rows, err := s.monitoringPool.Query(ctx, ` SELECT r.ai_platform_id, COUNT(*)::bigint AS actual_sample_count, COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count, COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count, MAX(r.completed_at) AS last_sampled_at FROM ( SELECT DISTINCT ON (business_date, question_id, ai_platform_id) id, tenant_id, ai_platform_id, completed_at, updated_at, created_at FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2 AND collector_type = $3 AND business_date = $4::date AND status = 'succeeded' AND question_id = ANY($5) ORDER BY business_date, question_id, ai_platform_id, COALESCE(completed_at, updated_at, created_at) DESC NULLS LAST, id DESC ) r LEFT JOIN question_monitor_parse_results p ON p.tenant_id = r.tenant_id AND p.run_id = r.id GROUP BY r.ai_platform_id `, tenantID, brandID, monitoringCollectorType, dateKey, questionIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load platform breakdown") } defer rows.Close() type row struct { MentionRate sql.NullFloat64 Top1MentionRate sql.NullFloat64 ActualSampleCount int64 LastSampledAt sql.NullTime } items := make(map[string]row) for rows.Next() { var platformID string var mentionedCount int64 var top1MentionedCount int64 var item row if scanErr := rows.Scan( &platformID, &item.ActualSampleCount, &mentionedCount, &top1MentionedCount, &item.LastSampledAt, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform breakdown") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount)) item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount)) items[platformID] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate platform breakdown") } breakdown := make([]MonitoringPlatformDaily, 0, len(platforms)) for _, platform := range platforms { item := items[platform.ID] if platformStats, ok := derived.ByDatePlatform[dateKey][platform.ID]; ok && platformStats.hasSamples() { item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate()) item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate()) item.ActualSampleCount = platformStats.Total } breakdown = append(breakdown, MonitoringPlatformDaily{ AIPlatformID: platform.ID, PlatformName: platform.Name, MentionRate: floatPointer(item.MentionRate), Top1MentionRate: floatPointer(item.Top1MentionRate), PlatformSampleStatus: deriveMonitoringPlatformSampleStatus(item.ActualSampleCount, item.ActualSampleCount, accessStates[platform.ID].AccessStatus), ActualSampleCount: item.ActualSampleCount, LastSampledAt: formatNullTime(item.LastSampledAt), }) } return breakdown, nil } func emptyPlatformBreakdown(platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState) []MonitoringPlatformDaily { breakdown := make([]MonitoringPlatformDaily, 0, len(platforms)) for _, platform := range platforms { breakdown = append(breakdown, MonitoringPlatformDaily{ AIPlatformID: platform.ID, PlatformName: platform.Name, PlatformSampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]), }) } return breakdown } func (s *MonitoringService) loadHotQuestions( ctx context.Context, tenantID, brandID int64, questions []monitoringConfiguredQuestion, businessDate time.Time, aiPlatformID *string, derived monitoringDerivedMetrics, ) ([]MonitoringHotQuestion, error) { if len(questions) == 0 { return []MonitoringHotQuestion{}, nil } questionIDs := configuredQuestionIDs(questions) selectedDate := businessDate.Format("2006-01-02") platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) rows, err := s.monitoringPool.Query(ctx, ` WITH latest_runs AS ( SELECT DISTINCT ON (r.question_id, r.ai_platform_id) r.question_id, r.ai_platform_id, r.question_hash, r.completed_at, r.updated_at, r.created_at, p.brand_mentioned FROM question_monitor_runs r LEFT JOIN question_monitor_parse_results p ON p.tenant_id = r.tenant_id AND p.run_id = r.id WHERE r.tenant_id = $1 AND r.brand_id = $2 AND r.collector_type = $3 AND r.status = 'succeeded' AND r.business_date = $4::date AND r.question_id = ANY($5) AND ($6::text[] IS NULL OR r.ai_platform_id = ANY($6)) ORDER BY r.question_id, r.ai_platform_id, COALESCE(r.completed_at, r.updated_at, r.created_at) DESC NULLS LAST, r.id DESC ), stats AS ( SELECT question_id, AVG(CASE WHEN brand_mentioned IS TRUE THEN 1 ELSE 0 END)::double precision AS mention_rate, MAX(completed_at) AS last_sampled_at FROM latest_runs GROUP BY question_id ), latest_hash AS ( SELECT DISTINCT ON (r.question_id) r.question_id, r.question_hash FROM latest_runs r ORDER BY r.question_id, COALESCE(r.completed_at, r.updated_at, r.created_at) DESC NULLS LAST ) SELECT s.question_id, h.question_hash, s.mention_rate, s.last_sampled_at FROM stats s LEFT JOIN latest_hash h ON h.question_id = s.question_id `, tenantID, brandID, monitoringCollectorType, selectedDate, questionIDs, nullableStringArray(platformQueryIDs)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load hot questions") } defer rows.Close() type questionStat struct { QuestionHash []byte MentionRate sql.NullFloat64 LastSampledAt sql.NullTime } stats := make(map[int64]questionStat, len(questions)) for rows.Next() { var questionID int64 var item questionStat if scanErr := rows.Scan(&questionID, &item.QuestionHash, &item.MentionRate, &item.LastSampledAt); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse hot questions") } stats[questionID] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate hot questions") } items := make([]MonitoringHotQuestion, 0, len(questions)) for _, question := range questions { item := MonitoringHotQuestion{ QuestionID: question.ID, QuestionHash: encodeQuestionHash(question.QuestionHash), QuestionText: question.QuestionText, } if stat, ok := stats[question.ID]; ok { if len(stat.QuestionHash) > 0 { item.QuestionHash = encodeQuestionHash(stat.QuestionHash) } item.MentionRate = floatPointer(stat.MentionRate) item.LastSampledAt = formatNullTime(stat.LastSampledAt) } if questionStatsByDate, ok := derived.ByDateQuestion[selectedDate]; ok { if derivedStats, ok := questionStatsByDate[question.ID]; ok && derivedStats.hasSamples() { item.MentionRate = derivedStats.mentionRate() } } items = append(items, item) } return items, nil } func (s *MonitoringService) loadHotQuestionsPage( ctx context.Context, tenantID, brandID int64, keywordID *int64, questionID *int64, businessDate time.Time, aiPlatformID *string, derived monitoringDerivedMetrics, pageReq MonitoringPageRequest, ) ([]MonitoringHotQuestion, MonitoringPage, error) { pageReq = normalizeMonitoringPageRequest(pageReq, defaultMonitoringListPageSize) var total int64 if err := s.businessPool.QueryRow(ctx, ` SELECT COUNT(*) FROM brand_questions q JOIN brand_keywords k ON k.id = q.keyword_id AND k.tenant_id = q.tenant_id AND k.brand_id = q.brand_id AND k.deleted_at IS NULL AND k.status = 'active' WHERE q.tenant_id = $1 AND q.brand_id = $2 AND q.deleted_at IS NULL AND q.status = 'active' AND ($3::bigint IS NULL OR q.keyword_id = $3) AND ($4::bigint IS NULL OR q.id = $4) AND ($5::text = '' OR q.question_text ILIKE '%' || $5 || '%') `, tenantID, brandID, nullableInt64(keywordID), nullableInt64(questionID), pageReq.Query).Scan(&total); err != nil { return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count hot questions") } page := monitoringPageFromRequest(pageReq, total) if total == 0 { return []MonitoringHotQuestion{}, page, nil } rows, err := s.businessPool.Query(ctx, ` SELECT q.id, q.keyword_id, q.question_text FROM brand_questions q JOIN brand_keywords k ON k.id = q.keyword_id AND k.tenant_id = q.tenant_id AND k.brand_id = q.brand_id AND k.deleted_at IS NULL AND k.status = 'active' WHERE q.tenant_id = $1 AND q.brand_id = $2 AND q.deleted_at IS NULL AND q.status = 'active' AND ($3::bigint IS NULL OR q.keyword_id = $3) AND ($4::bigint IS NULL OR q.id = $4) AND ($5::text = '' OR q.question_text ILIKE '%' || $5 || '%') ORDER BY q.keyword_id ASC, q.id ASC LIMIT $6 OFFSET $7 `, tenantID, brandID, nullableInt64(keywordID), nullableInt64(questionID), pageReq.Query, pageReq.PageSize, monitoringPageOffset(pageReq)) if err != nil { return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to load hot questions") } defer rows.Close() questions := make([]monitoringConfiguredQuestion, 0, pageReq.PageSize) for rows.Next() { var item monitoringConfiguredQuestion if scanErr := rows.Scan(&item.ID, &item.KeywordID, &item.QuestionText); scanErr != nil { return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to parse hot questions") } item.QuestionHash = seededQuestionHash(item.QuestionText) questions = append(questions, item) } if err := rows.Err(); err != nil { return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to iterate hot questions") } if len(questions) == 0 { return []MonitoringHotQuestion{}, page, nil } items, err := s.loadHotQuestions(ctx, tenantID, brandID, questions, businessDate, aiPlatformID, derived) if err != nil { return nil, MonitoringPage{}, err } return items, page, nil } func (s *MonitoringService) loadCitationRanking( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, accessStates map[string]monitoringAccessState, aiPlatformID *string, ) ([]MonitoringCitationRanking, error) { if questionIDs != nil && len(questionIDs) == 0 { return []MonitoringCitationRanking{}, nil } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) type citationRankingAggregate struct { CitedAnswerCount int64 CitedArticleCount int64 CitationSourceCount int64 SaaSSourceCount int64 SampleCount int64 } aggregates := make(map[string]citationRankingAggregate) rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_runs AS ( SELECT id, ai_platform_id FROM question_monitor_runs WHERE tenant_id = $1 AND ($2::bigint = 0 OR brand_id = $2) AND collector_type = $3 AND status = 'succeeded' AND business_date BETWEEN $4::date AND $5::date AND ($6::bigint[] IS NULL OR question_id = ANY($6)) AND ($7::text[] IS NULL OR ai_platform_id = ANY($7)) ), sample_counts AS ( SELECT ai_platform_id, COUNT(*)::bigint AS sample_count FROM filtered_runs GROUP BY ai_platform_id ), citation_counts AS ( SELECT fr.ai_platform_id, COUNT(cf.id)::bigint AS citation_source_count, COUNT(cf.id) FILTER ( WHERE cf.content_source_type IS NOT NULL AND cf.content_source_id IS NOT NULL )::bigint AS saas_source_count, COUNT(DISTINCT cf.run_id) FILTER ( WHERE cf.content_source_type IS NOT NULL AND cf.content_source_id IS NOT NULL )::bigint AS cited_answer_count, COUNT(DISTINCT cf.content_source_type || ':' || cf.content_source_id::text) FILTER ( WHERE cf.content_source_type IS NOT NULL AND cf.content_source_id IS NOT NULL )::bigint AS cited_article_count FROM filtered_runs fr JOIN monitoring_citation_facts cf ON cf.tenant_id = $1 AND cf.run_id = fr.id AND cf.business_date BETWEEN $4::date AND $5::date GROUP BY fr.ai_platform_id ) SELECT sc.ai_platform_id, sc.sample_count, COALESCE(cc.citation_source_count, 0)::bigint, COALESCE(cc.saas_source_count, 0)::bigint, COALESCE(cc.cited_answer_count, 0)::bigint, COALESCE(cc.cited_article_count, 0)::bigint FROM sample_counts sc LEFT JOIN citation_counts cc ON cc.ai_platform_id = sc.ai_platform_id `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") } defer rows.Close() for rows.Next() { var platformID string var sampleCount int64 var citationSourceCount int64 var saasSourceCount int64 var citedAnswerCount int64 var citedArticleCount int64 if scanErr := rows.Scan( &platformID, &sampleCount, &citationSourceCount, &saasSourceCount, &citedAnswerCount, &citedArticleCount, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } item := aggregates[platformID] item.SampleCount += sampleCount item.CitationSourceCount += citationSourceCount item.SaaSSourceCount += saasSourceCount item.CitedAnswerCount += citedAnswerCount item.CitedArticleCount += citedArticleCount aggregates[platformID] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking") } items := make([]MonitoringCitationRanking, 0, len(aggregates)) for platformID, aggregate := range aggregates { if aggregate.SaaSSourceCount == 0 { continue } items = append(items, MonitoringCitationRanking{ AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), SampleStatus: derivePlatformSampleStatus("", accessStates[platformID]), CitedAnswerCount: aggregate.CitedAnswerCount, CitedArticleCount: aggregate.CitedArticleCount, CitationSourceCount: aggregate.CitationSourceCount, SaaSSourceCount: aggregate.SaaSSourceCount, SaaSSourceRate: divideAsPointer(aggregate.SaaSSourceCount, aggregate.CitationSourceCount), CitationRate: divideAsPointer(aggregate.CitedAnswerCount, aggregate.SampleCount), }) } sort.Slice(items, func(left, right int) bool { if items[left].CitedAnswerCount != items[right].CitedAnswerCount { return items[left].CitedAnswerCount > items[right].CitedAnswerCount } if items[left].SaaSSourceCount != items[right].SaaSSourceCount { return items[left].SaaSSourceCount > items[right].SaaSSourceCount } if items[left].CitedArticleCount != items[right].CitedArticleCount { return items[left].CitedArticleCount > items[right].CitedArticleCount } return monitoringPlatformSortIndex(items[left].AIPlatformID) < monitoringPlatformSortIndex(items[right].AIPlatformID) }) return items, nil } func (s *MonitoringService) loadCitedArticles( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, aiPlatformID *string, ) ([]MonitoringCitedArticle, error) { pageReq := MonitoringPageRequest{Page: 1, PageSize: maxMonitoringListPageSize} items, _, err := s.loadCitedArticlesPage(ctx, tenantID, brandID, questionIDs, startDate, endDate, aiPlatformID, pageReq) return items, err } func (s *MonitoringService) loadCitedArticlesPage( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, aiPlatformID *string, pageReq MonitoringPageRequest, ) ([]MonitoringCitedArticle, MonitoringPage, error) { pageReq = normalizeMonitoringPageRequest(pageReq, defaultMonitoringListPageSize) if questionIDs != nil && len(questionIDs) == 0 { return []MonitoringCitedArticle{}, emptyMonitoringPage(pageReq), nil } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) startDateText := startDate.Format("2006-01-02") endDateText := endDate.Format("2006-01-02") var totalSampleCount int64 if err := s.monitoringPool.QueryRow(ctx, ` SELECT COUNT(*) FROM question_monitor_runs r WHERE r.tenant_id = $1 AND ($2::bigint = 0 OR r.brand_id = $2) AND r.collector_type = $3 AND r.status = 'succeeded' AND r.business_date BETWEEN $4::date AND $5::date AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) `, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)).Scan(&totalSampleCount); err != nil { return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count cited article denominator") } sourceRows, totalArticleCitationCount, totalSources, err := s.loadCitedArticleSourceRows(ctx, tenantID, brandID, questionIDs, startDateText, endDateText, platformQueryIDs, pageReq) if err != nil { return nil, MonitoringPage{}, err } page := monitoringPageFromRequest(pageReq, totalSources) if len(sourceRows) == 0 { return []MonitoringCitedArticle{}, page, nil } metaByKey, err := s.loadCitedArticleSourceMeta(ctx, tenantID, sourceRows) if err != nil { return nil, MonitoringPage{}, err } items := make([]MonitoringCitedArticle, 0, len(sourceRows)) for _, source := range sourceRows { item := metaByKey[monitoringContentSourceKey(source.SourceType, source.SourceID)] if item.SourceID == 0 { item = MonitoringCitedArticle{ ArticleTitle: "未命名文章", PublishPlatform: "外部文章", SourceType: source.SourceType, SourceID: source.SourceID, } } item.CitationCount = source.CitationCount item.CitationRate = divideAsPointer(source.CitationCount, totalSampleCount) item.SourceShare = divideAsPointer(source.CitationCount, totalArticleCitationCount) items = append(items, item) } return items, page, nil } type monitoringCitedArticleSourceRow struct { SourceType string SourceID int64 CitationCount int64 } func (s *MonitoringService) loadCitedArticleSourceRows( ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDateText, endDateText string, platformQueryIDs []string, pageReq MonitoringPageRequest, ) ([]monitoringCitedArticleSourceRow, int64, int64, error) { rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_sources AS ( SELECT cf.content_source_type, cf.content_source_id, COUNT(*)::bigint AS citation_count FROM monitoring_citation_facts cf JOIN question_monitor_runs r ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id WHERE cf.tenant_id = $1 AND ($2::bigint = 0 OR cf.brand_id = $2) AND cf.business_date BETWEEN $4::date AND $5::date AND cf.content_source_type IS NOT NULL AND cf.content_source_id IS NOT NULL AND r.collector_type = $3 AND r.status = 'succeeded' AND r.business_date BETWEEN $4::date AND $5::date AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) GROUP BY cf.content_source_type, cf.content_source_id ), counts AS ( SELECT COALESCE(SUM(citation_count), 0)::bigint AS total_citation_count, COUNT(*)::bigint AS total_source_count FROM filtered_sources ), paged_sources AS ( SELECT content_source_type, content_source_id, citation_count FROM filtered_sources ORDER BY citation_count DESC, content_source_type ASC, content_source_id ASC LIMIT $8 OFFSET $9 ) SELECT ps.content_source_type, ps.content_source_id, ps.citation_count, counts.total_citation_count, counts.total_source_count FROM counts LEFT JOIN paged_sources ps ON TRUE `, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs), pageReq.PageSize, monitoringPageOffset(pageReq)) if err != nil { return nil, 0, 0, response.ErrInternal(50041, "query_failed", "failed to load cited articles") } defer rows.Close() items := make([]monitoringCitedArticleSourceRow, 0, pageReq.PageSize) var totalCitationCount int64 var totalSourceCount int64 for rows.Next() { var sourceType sql.NullString var sourceID sql.NullInt64 var citationCount sql.NullInt64 if scanErr := rows.Scan( &sourceType, &sourceID, &citationCount, &totalCitationCount, &totalSourceCount, ); scanErr != nil { return nil, 0, 0, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") } if !sourceType.Valid || !sourceID.Valid || !citationCount.Valid { continue } item := monitoringCitedArticleSourceRow{ SourceType: strings.TrimSpace(sourceType.String), SourceID: sourceID.Int64, CitationCount: citationCount.Int64, } if item.SourceType == "" || item.SourceID <= 0 { continue } items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, 0, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles") } return items, totalCitationCount, totalSourceCount, nil } func (s *MonitoringService) loadCitedArticleSourceMeta(ctx context.Context, tenantID int64, sources []monitoringCitedArticleSourceRow) (map[string]MonitoringCitedArticle, error) { result := make(map[string]MonitoringCitedArticle, len(sources)) publishedIDs := make([]int64, 0, len(sources)) manualIDs := make([]int64, 0, len(sources)) for _, source := range sources { switch source.SourceType { case monitoringContentSourcePublishedArticle: publishedIDs = append(publishedIDs, source.SourceID) case monitoringContentSourceManualMark: manualIDs = append(manualIDs, source.SourceID) } } if len(publishedIDs) > 0 { rows, err := s.monitoringPool.Query(ctx, ` SELECT DISTINCT ON (article_id) article_id, COALESCE(NULLIF(article_title_snapshot, ''), '未命名文章') AS article_title, COALESCE(NULLIF(publish_platform_name_snapshot, ''), '已发布内容') AS publish_platform, COALESCE(NULLIF(original_url, ''), normalized_url) AS source_url FROM monitoring_article_url_aliases WHERE tenant_id = $1 AND confidence = 'high' AND article_id = ANY($2) ORDER BY article_id, updated_at DESC, id DESC `, tenantID, publishedIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load cited article metadata") } defer rows.Close() for rows.Next() { var articleID int64 var item MonitoringCitedArticle if scanErr := rows.Scan(&articleID, &item.ArticleTitle, &item.PublishPlatform, &item.SourceURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited article metadata") } value := articleID item.ArticleID = &value item.ArticleTitle = repairMonitoringMojibake(item.ArticleTitle) item.PublishPlatform = repairMonitoringMojibake(item.PublishPlatform) item.SourceType = monitoringContentSourcePublishedArticle item.SourceID = articleID result[monitoringContentSourceKey(item.SourceType, item.SourceID)] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited article metadata") } } if len(manualIDs) > 0 { rows, err := s.monitoringPool.Query(ctx, ` SELECT id, COALESCE(NULLIF(article_title, ''), '未命名文章') AS article_title, COALESCE(NULLIF(publish_platform, ''), '外部文章') AS publish_platform, COALESCE(NULLIF(original_url, ''), normalized_url) AS source_url FROM monitoring_user_marked_articles WHERE tenant_id = $1 AND id = ANY($2) `, tenantID, manualIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load marked article metadata") } defer rows.Close() for rows.Next() { var item MonitoringCitedArticle if scanErr := rows.Scan(&item.SourceID, &item.ArticleTitle, &item.PublishPlatform, &item.SourceURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse marked article metadata") } item.ArticleTitle = repairMonitoringMojibake(item.ArticleTitle) item.PublishPlatform = repairMonitoringMojibake(item.PublishPlatform) item.SourceType = monitoringContentSourceManualMark result[monitoringContentSourceKey(item.SourceType, item.SourceID)] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate marked article metadata") } } mediaItems, err := s.loadMediaSupplyCitedArticleSourceMeta(ctx, tenantID, sources) if err != nil { return nil, err } for key, item := range mediaItems { result[key] = item } return result, nil } func (s *MonitoringService) loadMediaSupplyCitedArticleSourceMeta(ctx context.Context, tenantID int64, sources []monitoringCitedArticleSourceRow) (map[string]MonitoringCitedArticle, error) { result := make(map[string]MonitoringCitedArticle) mediaItemIDs := make([]int64, 0, len(sources)) for _, source := range sources { if source.SourceType == monitoringContentSourceMediaSupplyOrderItem { mediaItemIDs = append(mediaItemIDs, source.SourceID) } } if len(mediaItemIDs) == 0 { return result, nil } rows, err := s.businessPool.Query(ctx, ` SELECT i.id, o.article_id, COALESCE(NULLIF(o.title, ''), NULLIF(i.resource_name_snapshot, ''), '未命名文章') AS article_title, COALESCE(NULLIF(i.resource_name_snapshot, ''), $3) AS publish_platform, i.external_article_url FROM media_supply_order_items i JOIN media_supply_orders o ON o.id = i.order_id WHERE o.tenant_id = $1 AND i.id = ANY($2) AND o.deleted_at IS NULL `, tenantID, mediaItemIDs, monitoringMediaSupplySubmissionCitationLabel) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load media supply citation metadata") } defer rows.Close() for rows.Next() { var item MonitoringCitedArticle var articleID sql.NullInt64 if scanErr := rows.Scan(&item.SourceID, &articleID, &item.ArticleTitle, &item.PublishPlatform, &item.SourceURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse media supply citation metadata") } item.ArticleID = nullableInt64Value(articleID) item.ArticleTitle = repairMonitoringMojibake(item.ArticleTitle) item.PublishPlatform = repairMonitoringMojibake(item.PublishPlatform) item.SourceType = monitoringContentSourceMediaSupplyOrderItem result[monitoringContentSourceKey(item.SourceType, item.SourceID)] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate media supply citation metadata") } return result, nil } func monitoringContentSourceKey(sourceType string, sourceID int64) string { return strings.TrimSpace(sourceType) + ":" + strconv.FormatInt(sourceID, 10) } func monitoringAliasContentKey(alias monitoringPublishedAlias) string { sourceType := strings.TrimSpace(alias.SourceType) sourceID := alias.SourceID if sourceType == "" { sourceType = "published_article" } if sourceID == 0 && alias.ArticleID > 0 { sourceID = alias.ArticleID } if sourceID > 0 { return sourceType + ":" + strconv.FormatInt(sourceID, 10) } key := strings.TrimSpace(alias.NormalizedURL) if key == "" { key = strings.TrimSpace(alias.OriginalURL) } return sourceType + ":url:" + key } func (s *MonitoringService) loadQuestionText(ctx context.Context, tenantID, brandID, questionID int64) (string, error) { var text string if err := s.businessPool.QueryRow(ctx, ` SELECT question_text FROM brand_questions WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL `, questionID, tenantID, brandID).Scan(&text); err != nil { if err == pgx.ErrNoRows { return "", response.ErrNotFound(40441, "question_not_found", "question not found") } return "", response.ErrInternal(50041, "query_failed", "failed to load question") } return text, nil } func (s *MonitoringService) loadLatestQuestionHash(ctx context.Context, tenantID, brandID, questionID int64, fromDate, toDate time.Time) ([]byte, error) { var hashBytes []byte if err := s.monitoringPool.QueryRow(ctx, ` SELECT question_hash FROM ( SELECT question_hash, business_date, COALESCE(completed_at, updated_at, created_at) AS event_at FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2 AND question_id = $3 AND collector_type = $4 AND business_date BETWEEN $5::date AND $6::date UNION ALL SELECT question_hash, business_date, COALESCE(completed_at, callback_received_at, leased_at, planned_at, updated_at, created_at) AS event_at FROM monitoring_collect_tasks WHERE tenant_id = $1 AND brand_id = $2 AND question_id = $3 AND collector_type = $4 AND business_date BETWEEN $5::date AND $6::date ) AS hashes ORDER BY business_date DESC, event_at DESC NULLS LAST LIMIT 1 `, tenantID, brandID, questionID, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02")).Scan(&hashBytes); err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, response.ErrInternal(50041, "query_failed", "failed to resolve question hash") } return hashBytes, nil } func (s *MonitoringService) loadLatestPlatformRuns(ctx context.Context, tenantID, brandID int64, brandName string, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) (map[string]monitoringLatestPlatformRun, error) { platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) rows, err := s.monitoringPool.Query(ctx, ` SELECT DISTINCT ON (r.ai_platform_id) r.ai_platform_id, r.id, r.completed_at, r.provider_model, r.raw_answer_text, r.raw_response_json, p.brand_mentioned, p.brand_mention_position FROM question_monitor_runs r LEFT JOIN question_monitor_parse_results p ON p.tenant_id = r.tenant_id AND p.run_id = r.id WHERE r.tenant_id = $1 AND r.brand_id = $2 AND r.question_id = $3 AND r.question_hash = $4 AND r.collector_type = $5 AND r.status = 'succeeded' AND r.business_date BETWEEN $6::date AND $7::date AND ($8::text[] IS NULL OR r.ai_platform_id = ANY($8)) ORDER BY r.ai_platform_id ASC, r.completed_at DESC NULLS LAST, r.id DESC `, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load question runs") } defer rows.Close() items := make(map[string]monitoringLatestPlatformRun) for rows.Next() { var item monitoringLatestPlatformRun var rawResponseJSON []byte if scanErr := rows.Scan( &item.PlatformID, &item.RunID, &item.CompletedAt, &item.ProviderModel, &item.AnswerText, &rawResponseJSON, &item.BrandMentioned, &item.BrandMentionPosition, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse latest question runs") } item.PlatformID = normalizeMonitoringPlatformID(item.PlatformID) if item.PlatformID == "" { continue } if item.AnswerText.Valid { item.AnswerText.String = repairMonitoringMojibake(item.AnswerText.String) } item.InlineCitations = extractMonitoringInlineCitationsFromRawResponse(rawResponseJSON, item.PlatformID) summary := resolveMonitoringAnswerParseSummary( item.AnswerText.String, brandName, monitoringStoredParseFields{ BrandMentioned: boolPointer(item.BrandMentioned), BrandMentionPosition: stringPointer(item.BrandMentionPosition), }, ) item.BrandMentioned = sql.NullBool{Bool: summary.BrandMentioned, Valid: true} item.BrandMentionPosition = pointerStringToNull(optionalString(summary.BrandMentionPosition)) existing, ok := items[item.PlatformID] if !ok || monitoringLatestPlatformRunIsNewer(item, existing) { items[item.PlatformID] = item } } return items, nil } func extractMonitoringInlineCitationsFromRawResponse(raw []byte, platformID string) []MonitoringSourceItem { if strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" { return nil } var payload map[string]interface{} if err := json.Unmarshal(raw, &payload); err != nil { return nil } items := extractMonitoringSourceItems(payload["inline_citation_candidates"]) if len(items) == 0 && normalizeMonitoringPlatformID(platformID) == "deepseek" { items = extractMonitoringSourceItems(payload["inline_links"]) if len(items) == 0 { items = extractMonitoringSourceItems(payload["source_panel_links"]) } } if len(items) == 0 && normalizeMonitoringPlatformID(platformID) == "kimi" { items = extractMonitoringSourceItems(payload["search_results"]) } if len(items) == 0 { return nil } result := make([]MonitoringSourceItem, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { normalizedURL := normalizeCitationURL(strings.TrimSpace(item.URL)) if normalizedURL == "" { normalizedURL = strings.TrimSpace(item.URL) } if normalizedURL == "" { continue } if _, exists := seen[normalizedURL]; exists { continue } seen[normalizedURL] = struct{}{} item.URL = normalizedURL if item.NormalizedURL == nil || strings.TrimSpace(*item.NormalizedURL) == "" { item.NormalizedURL = &normalizedURL } result = append(result, normalizeMonitoringSourceItemValue(item)) } if len(result) == 0 { return nil } return result } func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID int64, runIDs []int64) (map[int64][]MonitoringQuestionDetailCitation, error) { result := make(map[int64][]MonitoringQuestionDetailCitation) if len(runIDs) == 0 { return result, nil } aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, s.businessPool, tenantID) if err != nil { return nil, err } rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_citations AS ( SELECT cf.id, cf.run_id, cf.cited_url, cf.cited_title, cf.normalized_url, cf.host, cf.registrable_domain, cf.site_key, cf.resolution_status, cf.resolution_confidence FROM monitoring_citation_facts cf WHERE cf.tenant_id = $1 AND cf.run_id = ANY($2) ), domain_keys AS ( SELECT DISTINCT host, registrable_domain, site_key FROM filtered_citations ), domain_mappings AS ( SELECT dk.host, dk.registrable_domain, dk.site_key, dm.registrable_domain AS mapped_domain, dm.site_key AS mapped_site_key, dm.site_name AS mapped_site_name FROM domain_keys dk LEFT JOIN LATERAL ( SELECT mapping.registrable_domain, mapping.site_key, mapping.site_name FROM site_domain_mappings mapping WHERE mapping.is_active = TRUE AND mapping.registrable_domain = ANY( ARRAY( SELECT DISTINCT candidate FROM ( VALUES (dk.registrable_domain), (dk.site_key), (dk.host) UNION ALL SELECT array_to_string(host_parts.value[idx.value:array_length(host_parts.value, 1)], '.') FROM (SELECT string_to_array(dk.host, '.') AS value) host_parts CROSS JOIN generate_subscripts(host_parts.value, 1) AS idx(value) WHERE idx.value > 1 ) candidates(candidate) WHERE candidate IS NOT NULL AND candidate <> '' ) ) ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC LIMIT 1 ) dm ON TRUE ) SELECT fc.run_id, fc.cited_url, fc.cited_title, fc.normalized_url, COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name, COALESCE(dm.mapped_site_key, fc.site_key) AS site_key, fc.resolution_status, fc.resolution_confidence FROM filtered_citations fc LEFT JOIN domain_mappings dm ON dm.host = fc.host AND dm.registrable_domain = fc.registrable_domain AND dm.site_key = fc.site_key ORDER BY fc.run_id ASC, fc.id ASC `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load citations") } defer rows.Close() for rows.Next() { var runID int64 var citedURL string var citedTitle sql.NullString var normalizedURL string var siteName string var siteKey string var resolutionStatus string var resolutionConfidence string if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &normalizedURL, &siteName, &siteKey, &resolutionStatus, &resolutionConfidence); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations") } siteName = repairMonitoringMojibake(siteName) if citedTitle.Valid { citedTitle.String = repairMonitoringMojibake(citedTitle.String) } var articleID *int64 var articleTitle *string var sourceType *string var sourceID *int64 var sourceURL *string if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok { if alias.ArticleID > 0 { value := alias.ArticleID articleID = &value } title := repairMonitoringMojibake(alias.ArticleTitle) articleTitle = &title aliasSourceType := strings.TrimSpace(alias.SourceType) if aliasSourceType == "" { aliasSourceType = monitoringContentSourcePublishedArticle } sourceType = &aliasSourceType aliasSourceID := alias.SourceID if aliasSourceID == 0 && alias.ArticleID > 0 { aliasSourceID = alias.ArticleID } if aliasSourceID > 0 { sourceID = &aliasSourceID } aliasSourceURL := strings.TrimSpace(alias.OriginalURL) if aliasSourceURL == "" { aliasSourceURL = strings.TrimSpace(alias.NormalizedURL) } if aliasSourceURL != "" { sourceURL = &aliasSourceURL } resolutionStatus = "resolved" resolutionConfidence = "high" } result[runID] = append(result[runID], MonitoringQuestionDetailCitation{ CitedURL: citedURL, CitedTitle: nullableStringValue(citedTitle), SiteName: siteName, SiteKey: siteKey, FaviconURL: faviconURL(siteKey), ArticleID: articleID, SourceType: sourceType, SourceID: sourceID, SourceURL: sourceURL, ArticleTitle: articleTitle, ResolutionStatus: resolutionStatus, ResolutionConfidence: resolutionConfidence, }) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations") } return result, nil } func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID int64, runIDs []int64) ([]MonitoringQuestionCitationStats, error) { if len(runIDs) == 0 { return []MonitoringQuestionCitationStats{}, nil } rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_facts AS ( SELECT r.ai_platform_id, cf.id, cf.host, cf.registrable_domain, cf.site_key, cf.content_source_type, cf.content_source_id FROM question_monitor_runs r JOIN monitoring_citation_facts cf ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id WHERE r.tenant_id = $1 AND r.status = 'succeeded' AND r.id = ANY($2) ), domain_keys AS ( SELECT DISTINCT host, registrable_domain, site_key FROM filtered_facts ), domain_mappings AS ( SELECT dk.host, dk.registrable_domain, dk.site_key, dm.registrable_domain AS mapped_domain, dm.site_key AS mapped_site_key, dm.site_name AS mapped_site_name FROM domain_keys dk LEFT JOIN LATERAL ( SELECT mapping.registrable_domain, mapping.site_key, mapping.site_name FROM site_domain_mappings mapping WHERE mapping.is_active = TRUE AND mapping.registrable_domain = ANY( ARRAY( SELECT DISTINCT candidate FROM ( VALUES (dk.registrable_domain), (dk.site_key), (dk.host) UNION ALL SELECT array_to_string(host_parts.value[idx.value:array_length(host_parts.value, 1)], '.') FROM (SELECT string_to_array(dk.host, '.') AS value) host_parts CROSS JOIN generate_subscripts(host_parts.value, 1) AS idx(value) WHERE idx.value > 1 ) candidates(candidate) WHERE candidate IS NOT NULL AND candidate <> '' ) ) ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC LIMIT 1 ) dm ON TRUE ) SELECT ff.ai_platform_id, COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name, COALESCE(dm.mapped_site_key, ff.site_key) AS site_key, COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain, ff.content_source_type, ff.content_source_id FROM filtered_facts ff LEFT JOIN domain_mappings dm ON dm.host = ff.host AND dm.registrable_domain = ff.registrable_domain AND dm.site_key = ff.site_key ORDER BY ff.ai_platform_id ASC, ff.id ASC `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis") } defer rows.Close() type citationAnalysisKey struct { PlatformID string SiteKey string } aggregates := make(map[citationAnalysisKey]MonitoringQuestionCitationStats) totals := make(map[string]int64) for rows.Next() { var platformID string var siteName string var siteKey string var siteDomain string var sourceType sql.NullString var sourceID sql.NullInt64 if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &sourceType, &sourceID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } key := citationAnalysisKey{ PlatformID: platformID, SiteKey: siteKey, } item := aggregates[key] if item.SiteKey == "" { item = MonitoringQuestionCitationStats{ AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), SiteName: siteName, SiteKey: siteKey, SiteDomain: siteDomain, } } item.CitationCount++ if sourceType.Valid && strings.TrimSpace(sourceType.String) != "" && sourceID.Valid { item.ContentCitationCount++ } aggregates[key] = item totals[platformID]++ } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate question citation analysis") } items := make([]MonitoringQuestionCitationStats, 0, len(aggregates)) for _, item := range aggregates { item.CitationRate = divideAsPointer(item.CitationCount, totals[item.AIPlatformID]) items = append(items, item) } sort.Slice(items, func(left, right int) bool { if items[left].AIPlatformID != items[right].AIPlatformID { return monitoringPlatformSortIndex(items[left].AIPlatformID) < monitoringPlatformSortIndex(items[right].AIPlatformID) } if items[left].CitationCount != items[right].CitationCount { return items[left].CitationCount > items[right].CitationCount } if items[left].SiteName != items[right].SiteName { return items[left].SiteName < items[right].SiteName } return items[left].SiteKey < items[right].SiteKey }) return items, nil } func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID int64, runIDs []int64) ([]MonitoringQuestionContentCitation, error) { if len(runIDs) == 0 { return []MonitoringQuestionContentCitation{}, nil } rows, err := s.monitoringPool.Query(ctx, ` SELECT r.ai_platform_id, cf.content_source_type, cf.content_source_id FROM question_monitor_runs r JOIN monitoring_citation_facts cf ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id WHERE r.tenant_id = $1 AND r.status = 'succeeded' AND r.id = ANY($2) `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations") } defer rows.Close() type contentCitationKey struct { PlatformID string SourceType string SourceID int64 } aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation) totals := make(map[string]int64) for rows.Next() { var platformID string var sourceType sql.NullString var sourceID sql.NullInt64 if scanErr := rows.Scan(&platformID, &sourceType, &sourceID); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } totals[platformID]++ normalizedSourceType := strings.TrimSpace(sourceType.String) if !sourceType.Valid || normalizedSourceType == "" || !sourceID.Valid || sourceID.Int64 <= 0 { continue } key := contentCitationKey{ PlatformID: platformID, SourceType: normalizedSourceType, SourceID: sourceID.Int64, } item := aggregates[key] if item.SourceID == 0 { item = MonitoringQuestionContentCitation{ SourceType: normalizedSourceType, SourceID: sourceID.Int64, AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), } } item.CitationCount++ aggregates[key] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate content citations") } if len(aggregates) == 0 { return []MonitoringQuestionContentCitation{}, nil } sourceRows := make([]monitoringCitedArticleSourceRow, 0, len(aggregates)) for key := range aggregates { sourceRows = append(sourceRows, monitoringCitedArticleSourceRow{ SourceType: key.SourceType, SourceID: key.SourceID, }) } metaByKey, err := s.loadCitedArticleSourceMeta(ctx, tenantID, sourceRows) if err != nil { return nil, err } items := make([]MonitoringQuestionContentCitation, 0, len(aggregates)) for key, item := range aggregates { if meta := metaByKey[monitoringContentSourceKey(key.SourceType, key.SourceID)]; meta.SourceID > 0 { item.ArticleID = meta.ArticleID item.ArticleTitle = meta.ArticleTitle item.PublishPlatform = meta.PublishPlatform item.SourceURL = meta.SourceURL } if strings.TrimSpace(item.ArticleTitle) == "" { item.ArticleTitle = "未命名文章" } if strings.TrimSpace(item.PublishPlatform) == "" { item.PublishPlatform = "外部文章" } item.CitationRate = divideAsPointer(item.CitationCount, totals[item.AIPlatformID]) items = append(items, item) } sort.Slice(items, func(left, right int) bool { if items[left].AIPlatformID != items[right].AIPlatformID { return monitoringPlatformSortIndex(items[left].AIPlatformID) < monitoringPlatformSortIndex(items[right].AIPlatformID) } if items[left].CitationCount != items[right].CitationCount { return items[left].CitationCount > items[right].CitationCount } if items[left].SourceType != items[right].SourceType { return items[left].SourceType < items[right].SourceType } return items[left].SourceID < items[right].SourceID }) return items, nil } func (s *MonitoringService) ensureClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) error { online, err := s.isClientOnline(ctx, tenantID, workspaceID, clientID) if err != nil { return err } if !online { return response.ErrConflict(40941, "desktop_client_offline", "current user's monitoring desktop client is offline") } return nil } func (s *MonitoringService) isClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) (bool, error) { var lastSeenAt sql.NullTime err := s.businessPool.QueryRow(ctx, ` SELECT last_seen_at FROM desktop_clients WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND revoked_at IS NULL `, clientID, tenantID, workspaceID).Scan(&lastSeenAt) if err != nil { if err == pgx.ErrNoRows { return false, nil } return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client") } if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > desktopClientPresenceTTL { return false, nil } return true, nil } func parseDetailDateRange(dateFrom, dateTo string) (time.Time, time.Time, error) { return parseDetailDateRangeAt(dateFrom, dateTo, time.Now(), monitoringBusinessLocation()) } func parseDetailDateRangeAt(dateFrom, dateTo string, now time.Time, loc *time.Location) (time.Time, time.Time, error) { if strings.TrimSpace(dateFrom) == "" && strings.TrimSpace(dateTo) == "" { today := monitoringBusinessDayAt(now, loc) return today, today, nil } if strings.TrimSpace(dateFrom) == "" || strings.TrimSpace(dateTo) == "" { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_from and date_to must be provided together") } fromDate, err := parseMonitoringBusinessDateInLocation(dateFrom, loc) if err != nil { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_from must use YYYY-MM-DD") } toDate, err := parseMonitoringBusinessDateInLocation(dateTo, loc) if err != nil { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_to must use YYYY-MM-DD") } if fromDate.After(toDate) { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_from must not be later than date_to") } return fromDate, toDate, nil } func resolveDashboardDateWindow(days int, businessDate string) (time.Time, time.Time, error) { return resolveDashboardDateWindowAt(days, businessDate, time.Now(), monitoringBusinessLocation()) } func resolveDashboardDateWindowAt(days int, businessDate string, now time.Time, loc *time.Location) (time.Time, time.Time, error) { today := monitoringBusinessDayAt(now, loc) earliestAllowed := today.AddDate(0, 0, -(maxTrackingDays - 1)) endDate := today if strings.TrimSpace(businessDate) != "" { parsedDate, err := parseMonitoringBusinessDateInLocation(businessDate, loc) if err != nil { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_business_date", "business_date must use YYYY-MM-DD") } if parsedDate.After(today) { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_business_date", "business_date must not be later than today") } if parsedDate.Before(earliestAllowed) { return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_business_date", "business_date must be within the latest 30 days") } endDate = parsedDate } if days <= 0 { days = maxTrackingDays } days = normalizeTrackingDays(days) startDate := endDate.AddDate(0, 0, -(days - 1)) if startDate.Before(earliestAllowed) { startDate = earliestAllowed } return startDate, endDate, nil } func trackingDateWindow(days int) (time.Time, time.Time) { endDate := monitoringBusinessToday() startDate := endDate.AddDate(0, 0, -(days - 1)) return startDate, endDate } func monitoringBusinessToday() time.Time { return monitoringBusinessDayAt(time.Now(), monitoringBusinessLocation()) } func monitoringBusinessDayAt(now time.Time, loc *time.Location) time.Time { if loc == nil { loc = time.Local } localNow := now.In(loc) return time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc) } func parseMonitoringBusinessDateInLocation(value string, loc *time.Location) (time.Time, error) { if loc == nil { loc = time.Local } return time.ParseInLocation("2006-01-02", strings.TrimSpace(value), loc) } func monitoringRecentMetricsStart(startDate, endDate time.Time) time.Time { recentStart := endDate.AddDate(0, 0, -1) if startDate.After(recentStart) { return startDate } return recentStart } func normalizeTrackingDays(days int) int { if days <= 0 { return defaultTrackingDays } if days > maxTrackingDays { return maxTrackingDays } return days } func normalizeCitationWindowDays(days int) int { if days == maxTrackingDays { return maxTrackingDays } return defaultTrackingDays } func resolveMonitoringPlatforms(enabled []string) []monitoringPlatformMetadata { if len(enabled) == 0 { return nil } platforms := make([]monitoringPlatformMetadata, 0, len(enabled)) seen := make(map[string]struct{}, len(enabled)) for _, item := range enabled { id := normalizeMonitoringPlatformID(item) if id == "" { continue } if _, supported := monitoringPlatformNames[id]; !supported { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} platforms = append(platforms, monitoringPlatformMetadata{ ID: id, Name: platformDisplayName(id), }) } if len(platforms) == 0 { return nil } return platforms } func intersectMonitoringPlatforms(enabled []monitoringPlatformMetadata, requested []string) ([]monitoringPlatformMetadata, error) { if len(requested) == 0 { return enabled, nil } enabledIndex := make(map[string]monitoringPlatformMetadata, len(enabled)) for _, platform := range enabled { enabledIndex[platform.ID] = platform } result := make([]monitoringPlatformMetadata, 0, len(requested)) seen := make(map[string]struct{}, len(requested)) for _, raw := range requested { id := normalizeMonitoringPlatformID(raw) if id == "" { continue } if _, duplicate := seen[id]; duplicate { continue } seen[id] = struct{}{} platform, ok := enabledIndex[id] if !ok { return nil, response.ErrBadRequest(40031, "platform_not_enabled", fmt.Sprintf("platform %s is not enabled for this tenant", id)) } result = append(result, platform) } if len(result) == 0 { return nil, response.ErrBadRequest(40031, "invalid_platform_ids", "platform_ids contains no usable platform") } return result, nil } func filterMonitoringPlatforms(platforms []monitoringPlatformMetadata, aiPlatformID *string) []monitoringPlatformMetadata { platformID := normalizedOptionalMonitoringPlatformID(aiPlatformID) if platformID == "" { return platforms } filtered := make([]monitoringPlatformMetadata, 0, 1) for _, platform := range platforms { if platform.ID == platformID { filtered = append(filtered, platform) return filtered } } return nil } func platformDisplayName(platformID string) string { platformID = normalizeMonitoringPlatformID(platformID) if name, ok := monitoringPlatformNames[platformID]; ok { return name } if platformID == "" { return "未知平台" } return strings.ToUpper(platformID) } func normalizedOptionalString(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) } func mergeWeightedNullFloat(left sql.NullFloat64, leftWeight int64, right sql.NullFloat64, rightWeight int64) sql.NullFloat64 { switch { case left.Valid && right.Valid && leftWeight > 0 && rightWeight > 0: totalWeight := leftWeight + rightWeight if totalWeight <= 0 { return sql.NullFloat64{} } return sql.NullFloat64{ Float64: ((left.Float64 * float64(leftWeight)) + (right.Float64 * float64(rightWeight))) / float64(totalWeight), Valid: true, } case right.Valid: return right default: return left } } func maxNullTime(left, right sql.NullTime) sql.NullTime { switch { case !left.Valid: return right case !right.Valid: return left case right.Time.After(left.Time): return right default: return left } } func mergeMonitoringSampleStatus(left, right string) string { priorities := map[string]int{ "sampled": 4, "partial": 3, "pending": 2, "not_logged_in": 1, "unavailable": 0, "unsampled": 0, } if priorities[strings.TrimSpace(right)] > priorities[strings.TrimSpace(left)] { return right } return left } func monitoringLatestPlatformRunIsNewer(candidate, current monitoringLatestPlatformRun) bool { switch { case candidate.CompletedAt.Valid && !current.CompletedAt.Valid: return true case !candidate.CompletedAt.Valid && current.CompletedAt.Valid: return false case candidate.CompletedAt.Valid && current.CompletedAt.Valid: if candidate.CompletedAt.Time.After(current.CompletedAt.Time) { return true } if candidate.CompletedAt.Time.Before(current.CompletedAt.Time) { return false } } return candidate.RunID > current.RunID } func derivePlatformSampleStatus(rawStatus string, accessState monitoringAccessState) string { switch strings.TrimSpace(rawStatus) { case "sampled": return "sampled" case "pending": return "pending" case "not_logged_in": return "not_logged_in" case "unavailable": return "unavailable" } switch accessState.AccessStatus { case "not_logged_in": return "not_logged_in" case "unavailable": return "unavailable" case "accessible": return "pending" default: return "pending" } } func divideAsPointer(numerator, denominator int64) *float64 { if denominator <= 0 { return nil } value := float64(numerator) / float64(denominator) return &value } func pointerFloat64ToNull(value *float64) sql.NullFloat64 { if value == nil { return sql.NullFloat64{} } return sql.NullFloat64{Float64: *value, Valid: true} } func pointerStringToNull(value *string) sql.NullString { if value == nil { return sql.NullString{} } return sql.NullString{String: *value, Valid: true} } func floatPointer(value sql.NullFloat64) *float64 { if !value.Valid { return nil } v := value.Float64 return &v } func formatNullTime(value sql.NullTime) *string { if !value.Valid { return nil } text := value.Time.UTC().Format(time.RFC3339) return &text } func stringPointer(value sql.NullString) *string { if !value.Valid { return nil } v := value.String return &v } func nullableStringValue(value sql.NullString) *string { if !value.Valid { return nil } v := value.String return &v } func boolPointer(value sql.NullBool) *bool { if !value.Valid { return nil } v := value.Bool return &v } func nullableInt64Value(value sql.NullInt64) *int64 { if !value.Valid { return nil } v := value.Int64 return &v } func nullableInt64(value *int64) interface{} { if value == nil { return nil } return *value } func nullableInt64Array(values []int64) interface{} { if values == nil { return nil } return values } func nullableStringArray(values []string) interface{} { if len(values) == 0 { return nil } return values } func nullableString(value *string) interface{} { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return trimmed } func encodeQuestionHash(hashBytes []byte) string { if len(hashBytes) == 0 { return "" } return "v1:" + hex.EncodeToString(hashBytes) } func decodeQuestionHash(value string) ([]byte, error) { trimmed := strings.TrimSpace(value) trimmed = strings.TrimPrefix(trimmed, "v1:") if trimmed == "" { return nil, fmt.Errorf("empty hash") } return hex.DecodeString(trimmed) } func seededQuestionHash(text string) []byte { digest := md5.Sum([]byte(strings.TrimSpace(strings.ToLower(text)))) return digest[:] } func faviconURL(siteKey string) string { siteKey = strings.TrimSpace(siteKey) if siteKey == "" { return "" } return fmt.Sprintf("https://%s/favicon.ico", siteKey) }