package app import ( "context" "crypto/md5" "database/sql" "encoding/hex" "encoding/json" "fmt" "sort" "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 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"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"` } type MonitoringCitationSummaryResponse struct { CitationWindow MonitoringDashboardWindow `json:"citation_window"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"` } 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"` } 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"` 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"` 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"` 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 monitoringPlatformMetadata struct { ID string Name string } type monitoringConfiguredQuestion struct { ID int64 KeywordID int64 QuestionText string QuestionHash []byte } 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, days int, businessDate string, aiPlatformID *string, ) (*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 } platforms := defaultMonitoringPlatformMetadata() selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID) filteredPlatforms := filterMonitoringPlatforms(platforms, selectedPlatformID) startDate, endDate, err := resolveDashboardDateWindow(days, businessDate) if err != nil { return nil, err } derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate, selectedPlatformID) if err != nil { return nil, err } overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, 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, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics) if err != nil { return nil, err } hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, selectedPlatformID, derivedMetrics) 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, CitationRanking: []MonitoringCitationRanking{}, CitedArticles: []MonitoringCitedArticle{}, }, nil } func (s *MonitoringService) CitationSummary( ctx context.Context, days int, brandID int64, keywordID *int64, businessDate string, aiPlatformID *string, ) (*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 } 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, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID) 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, }, nil } func (s *MonitoringService) loadDashboardRuntime(ctx context.Context, actor auth.Actor, workspaceID int64, quota monitoringQuotaConfig) (MonitoringDashboardRuntime, error) { online := false if quota.PrimaryClientID != nil { var err error online, err = s.isClientOnline(ctx, actor.TenantID, workspaceID, *quota.PrimaryClientID) if err != nil { return MonitoringDashboardRuntime{}, err } } return MonitoringDashboardRuntime{ CurrentUserClientOnline: online, PlatformAuthorizationStatus: monitoringPlatformAuthorizationStatus(quota), AuthorizedMonitoringPlatformIDs: append([]string(nil), quota.EnabledPlatforms...), AuthorizedMonitoringPlatformCount: len(quota.EnabledPlatforms), }, 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, 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 } limitApplied := false if len(configuredQuestions) > monitoringCollectNowQuestionLimit { configuredQuestions = configuredQuestions[:monitoringCollectNowQuestionLimit] limitApplied = true } if len(configuredQuestions) == 0 { return &MonitoringCollectNowResponse{ CollectionMode: quota.CollectionMode, RefreshedTaskCount: 0, CreatedTaskCount: 0, LeasedTaskCount: 0, CompletedTaskCount: 0, HasEffectiveSnapshot: true, Message: "当前关键词下暂无品牌库问题,未创建采集任务", }, nil } clientID, err := s.resolveCollectNowClientID(ctx, actor.TenantID, workspaceID, actor.UserID, quota.PrimaryClientID, options.TargetClientID) 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") } if err := s.ensureClientOnline(ctx, actor.TenantID, workspaceID, *clientID); err != nil { return nil, err } targetPlatformIDs, err := s.loadMonitoringClientPlatformIDs(ctx, actor.TenantID, workspaceID, *clientID) if err != nil { return nil, err } platforms := resolveMonitoringPlatforms(targetPlatformIDs) 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) executionOwner := "legacy" if s.shouldUseDesktopTasksExecution(actor.TenantID) { executionOwner = "desktop_tasks" } 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 { if strings.TrimSpace(existingRequest.TargetClientID) == clientID.String() { 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); err != nil { return nil, err } interruptGeneration, err := s.nextCollectNowInterruptGeneration(ctx, tx, *clientID, executionOwner) if err != nil { return nil, err } refreshedCount, createdCount, leasedCount, completedCount, interruptTaskIDs, err := s.ensureCollectNowTasks( ctx, tx, actor.TenantID, brand.ID, configuredQuestions, platforms, todayTime, *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, ) if err != nil { return nil, err } } abortedQueuedCount := int64(0) if options.Preempt { abortedQueuedCount, err = s.deferQueuedNormalTasks( ctx, tx, actor.TenantID, todayTime, brand.ID, configuredQuestionIDs(configuredQuestions), monitoringPlatformIDs(platforms), *clientID, requestID, ttlExpiresAt, ) if err != nil { return nil, err } } requestScopeJSON, err := json.Marshal(map[string]any{ "brand_id": brand.ID, "keyword_id": keywordID, "question_ids": questionIDs, "platform_ids": platformIDs, "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(len(interruptTaskIDs)), 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, *clientID, affectedTaskCount, interruptGeneration, func() []int64 { if !options.Preempt { return nil } return interruptTaskIDs }(), ); 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, fallbackLegacyTaskIDs, dispatchErr := s.dispatchMonitorDesktopTasks(ctx, phase2TaskSpecs) if dispatchErr != nil { if fallbackErr := s.fallbackMonitorDesktopTasksToLegacy(ctx, monitorDesktopTaskSpecIDs(phase2TaskSpecs)); fallbackErr != nil { return nil, fallbackErr } phase2DispatchReady = false phase2TaskSpecs = nil if s.logger != nil { s.logger.Warn("phase2 monitor desktop dispatch failed, falling back to legacy execution", zap.Error(dispatchErr), zap.Int("phase2_task_count", phase2TaskCount), ) } } else { if len(fallbackLegacyTaskIDs) > 0 { if fallbackErr := s.fallbackMonitorDesktopTasksToLegacy(ctx, fallbackLegacyTaskIDs); fallbackErr != nil { return nil, fallbackErr } } phase2PublishedTasks = publishedTasks } } phase2InterruptTargets := make([]monitorDesktopInterruptTarget, 0) phase2DeferredTasks := make([]*repository.DesktopTask, 0) if options.Preempt && phase2DispatchReady { excludedMonitorTaskIDs := make([]int64, 0, len(phase2TaskSpecs)) for _, spec := range phase2TaskSpecs { excludedMonitorTaskIDs = append(excludedMonitorTaskIDs, spec.MonitorTaskID) } phase2DeferredTasks, err = s.deferQueuedPhase2MonitorTasks(ctx, *clientID, excludedMonitorTaskIDs) if err != nil { return nil, err } 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") } } phase2InterruptTargets, err = s.requestPhase2MonitorInterrupts(ctx, *clientID, excludedMonitorTaskIDs, interruptGeneration) if err != nil { return nil, err } if len(phase2InterruptTargets) > 0 { if _, updateErr := s.monitoringPool.Exec(ctx, ` UPDATE monitoring_collect_requests SET interrupt_requested_count = interrupt_requested_count + $2, updated_at = NOW() WHERE request_id = $1 `, requestID, len(phase2InterruptTargets)); updateErr != nil { return nil, response.ErrInternal(50041, "request_update_failed", "failed to persist phase2 interrupt request 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), ) } } for _, target := range phase2InterruptTargets { publishPhase2MonitorControlEvent(context.Background(), s.rabbitMQ, s.logger, target) } hasEffectiveSnapshot := leasedCount > 0 || completedCount > 0 totalInterruptRequestedCount := int64(len(interruptTaskIDs) + len(phase2InterruptTargets)) 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: totalInterruptRequestedCount, 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, ) (*uuid.UUID, error) { if preferredClientID != nil { if err := s.ensureClientBelongsToUser(ctx, tenantID, workspaceID, userID, *preferredClientID); err != nil { return nil, err } return preferredClientID, nil } 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) ensureClientBelongsToUser( ctx context.Context, tenantID, workspaceID, userID int64, clientID uuid.UUID, ) 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 user_id = $4 AND revoked_at IS NULL AND last_seen_at IS NOT NULL AND last_seen_at >= NOW() - $5::interval ) `, clientID, tenantID, workspaceID, userID, formatPgInterval(desktopClientPresenceTTL)).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, clientID uuid.UUID, executionOwner string, ) (int, error) { var next int 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 = $1 `, clientID).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 = $1 `, clientID).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 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.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 (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 { 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, question.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, targetClientID uuid.UUID, interruptGeneration int, executionOwner string, ) (int64, 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 interruptTaskIDs := make([]int64, 0) for _, question := range questions { for _, platform := range platforms { tag, err := tx.Exec(ctx, ` UPDATE monitoring_collect_tasks SET question_hash = $7, 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, platform.ID, monitoringCollectorType, runMode, question.QuestionHash, dateText, targetClientID, interruptGeneration, executionOwner) if err != nil { return 0, 0, 0, 0, nil, response.ErrInternal(50041, "update_failed", "failed to refresh monitoring tasks") } refreshedCount += tag.RowsAffected() rows, err := tx.Query(ctx, ` UPDATE monitoring_collect_tasks SET question_hash = $7, 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' RETURNING id `, tenantID, brandID, question.ID, platform.ID, monitoringCollectorType, runMode, question.QuestionHash, dateText, targetClientID, interruptGeneration) if err != nil { return 0, 0, 0, 0, nil, response.ErrInternal(50041, "update_failed", "failed to promote leased monitoring tasks") } for rows.Next() { var taskID int64 if scanErr := rows.Scan(&taskID); scanErr != nil { rows.Close() return 0, 0, 0, 0, nil, response.ErrInternal(50041, "scan_failed", "failed to parse promoted leased monitoring task") } interruptTaskIDs = append(interruptTaskIDs, taskID) } if err := rows.Err(); err != nil { rows.Close() return 0, 0, 0, 0, nil, response.ErrInternal(50041, "scan_failed", "failed to iterate promoted leased monitoring tasks") } rows.Close() tag, err = tx.Exec(ctx, ` INSERT INTO monitoring_collect_tasks ( tenant_id, brand_id, question_id, question_hash, 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, $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, platform.ID, monitoringCollectorType, runMode, dateText, targetClientID, interruptGeneration, fmt.Sprintf("%d:%d:%s", tenantID, brandID, dateText), executionOwner) if err != nil { return 0, 0, 0, 0, nil, 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, nil, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring tasks") } return refreshedCount, createdCount, leasedCount, completedCount, interruptTaskIDs, 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.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.loadMonitoringClientPlatformIDs(ctx, actor.TenantID, workspaceID, *clientID) if err != nil { return cfg, err } cfg.EnabledPlatforms = platforms return cfg, 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) 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 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.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 (s *MonitoringService) loadDerivedParseMetrics( ctx context.Context, tenantID, brandID int64, brandName string, startDate, endDate time.Time, aiPlatformID *string, ) (monitoringDerivedMetrics, error) { metrics := newMonitoringDerivedMetrics() platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) rows, err := s.monitoringPool.Query(ctx, ` SELECT r.business_date, r.ai_platform_id, r.question_id, r.raw_answer_text, p.brand_mentioned, p.brand_mention_position, p.first_recommended, p.sentiment_label, p.matched_brand_terms 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 BETWEEN $4::date AND $5::date AND ($6::text[] IS NULL OR r.ai_platform_id = ANY($6)) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs)) if err != nil { return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics") } defer rows.Close() for rows.Next() { var businessDate time.Time var platformID string var questionID int64 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( &businessDate, &platformID, &questionID, &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), }, ) dateKey := businessDate.Format("2006-01-02") dateStats := metrics.ByDate[dateKey] dateStats.add(summary) metrics.ByDate[dateKey] = dateStats platformStatsByDate, ok := metrics.ByDatePlatform[dateKey] if !ok { platformStatsByDate = make(map[string]monitoringDerivedRateStats) metrics.ByDatePlatform[dateKey] = platformStatsByDate } platformStats := platformStatsByDate[platformID] platformStats.add(summary) platformStatsByDate[platformID] = platformStats questionStatsByDate, ok := metrics.ByDateQuestion[dateKey] if !ok { questionStatsByDate = make(map[int64]monitoringDerivedRateStats) metrics.ByDateQuestion[dateKey] = questionStatsByDate } questionStatsForDate := questionStatsByDate[questionID] questionStatsForDate.add(summary) questionStatsByDate[questionID] = questionStatsForDate questionStats := metrics.ByQuestion[questionID] questionStats.add(summary) metrics.ByQuestion[questionID] = questionStats } if err := rows.Err(); err != nil { return metrics, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring derived metrics") } return metrics, 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, startDate, endDate time.Time, collectionMode string, aiPlatformID *string, derived monitoringDerivedMetrics, ) (MonitoringOverview, time.Time, error) { 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) 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) 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, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) { 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) 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 stats AS ( SELECT r.question_id, AVG(CASE WHEN p.brand_mentioned IS TRUE THEN 1 ELSE 0 END)::double precision AS mention_rate, MAX(r.completed_at) AS last_sampled_at 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)) GROUP BY r.question_id ), latest_hash AS ( SELECT DISTINCT ON (r.question_id) r.question_id, r.question_hash FROM question_monitor_runs r 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.business_date DESC, COALESCE(r.completed_at, r.updated_at, r.created_at) DESC NULLS LAST, r.id DESC ) 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) 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) aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) if err != nil { return nil, err } type citationRankingAggregate struct { CitedAnswerCount int64 CitedArticleCount int64 CitationSourceCount int64 SaaSSourceCount int64 SampleCount int64 } aggregates := make(map[string]citationRankingAggregate) rows, err := s.monitoringPool.Query(ctx, ` SELECT r.ai_platform_id, COUNT(*) AS sample_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)) GROUP BY r.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 if scanErr := rows.Scan(&platformID, &sampleCount); 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 aggregates[platformID] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking") } citedRuns := make(map[string]map[int64]struct{}) citedArticles := make(map[string]map[int64]struct{}) rows, err = s.monitoringPool.Query(ctx, ` SELECT r.ai_platform_id, cf.run_id, cf.cited_url, cf.normalized_url 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 ($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, 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 runID int64 var citedURL string var normalizedURL string if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } item := aggregates[platformID] item.CitationSourceCount++ if platformID != "qwen" { if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok { item.SaaSSourceCount++ if _, exists := citedRuns[platformID]; !exists { citedRuns[platformID] = make(map[int64]struct{}) } citedRuns[platformID][runID] = struct{}{} if _, exists := citedArticles[platformID]; !exists { citedArticles[platformID] = make(map[int64]struct{}) } citedArticles[platformID][alias.ArticleID] = struct{}{} } } aggregates[platformID] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking") } for platformID, item := range aggregates { item.CitedAnswerCount = int64(len(citedRuns[platformID])) item.CitedArticleCount = int64(len(citedArticles[platformID])) aggregates[platformID] = item } 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) { if questionIDs != nil && len(questionIDs) == 0 { return []MonitoringCitedArticle{}, nil } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) if err != nil { return nil, err } 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, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)).Scan(&totalSampleCount); err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to count cited article denominator") } rows, err := s.monitoringPool.Query(ctx, ` SELECT cf.cited_url, cf.normalized_url 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 r.ai_platform_id <> 'qwen' 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, 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 cited articles") } defer rows.Close() type citedArticleAggregate struct { ArticleID int64 ArticleTitle string PublishPlatform string CitationCount int64 } aggregates := make(map[int64]citedArticleAggregate) var totalArticleCitationCount int64 for rows.Next() { var citedURL string var normalizedURL string if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") } alias, ok := aliasIndex.Match(citedURL, normalizedURL) if !ok { continue } item := aggregates[alias.ArticleID] if item.ArticleID == 0 { item.ArticleID = alias.ArticleID item.ArticleTitle = alias.ArticleTitle item.PublishPlatform = alias.PublishPlatform } item.CitationCount++ totalArticleCitationCount++ aggregates[alias.ArticleID] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles") } items := make([]MonitoringCitedArticle, 0, len(aggregates)) for _, item := range aggregates { items = append(items, MonitoringCitedArticle{ ArticleID: item.ArticleID, ArticleTitle: item.ArticleTitle, PublishPlatform: item.PublishPlatform, CitationCount: item.CitationCount, CitationRate: divideAsPointer(item.CitationCount, totalSampleCount), SourceShare: divideAsPointer(item.CitationCount, totalArticleCitationCount), }) } sort.Slice(items, func(left, right int) bool { if items[left].CitationCount != items[right].CitationCount { return items[left].CitationCount > items[right].CitationCount } return items[left].ArticleID < items[right].ArticleID }) if len(items) > 10 { items = items[:10] } return items, nil } 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, 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 if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok { articleID = &alias.ArticleID title := repairMonitoringMojibake(alias.ArticleTitle) articleTitle = &title resolutionStatus = "resolved" resolutionConfidence = "high" } result[runID] = append(result[runID], MonitoringQuestionDetailCitation{ CitedURL: citedURL, CitedTitle: nullableStringValue(citedTitle), SiteName: siteName, SiteKey: siteKey, FaviconURL: faviconURL(siteKey), ArticleID: articleID, 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 } aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) if err != nil { return nil, err } rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_facts AS ( SELECT r.ai_platform_id, cf.id, cf.cited_url, cf.normalized_url, cf.host, cf.registrable_domain, cf.site_key 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, ff.cited_url, ff.normalized_url, 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 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 citedURL string var normalizedURL string var siteName string var siteKey string var siteDomain string if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); 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 platformID != "qwen" { if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok { 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 } aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) if err != nil { return nil, err } rows, err := s.monitoringPool.Query(ctx, ` SELECT r.ai_platform_id, cf.cited_url, cf.normalized_url 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.ai_platform_id <> 'qwen' 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 ArticleID int64 } aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation) totals := make(map[string]int64) for rows.Next() { var platformID string var citedURL string var normalizedURL string if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } totals[platformID]++ alias, ok := aliasIndex.Match(citedURL, normalizedURL) if !ok { continue } key := contentCitationKey{ PlatformID: platformID, ArticleID: alias.ArticleID, } item := aggregates[key] if item.ArticleID == 0 { item = MonitoringQuestionContentCitation{ ArticleID: alias.ArticleID, ArticleTitle: alias.ArticleTitle, PublishPlatform: alias.PublishPlatform, 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") } items := make([]MonitoringQuestionContentCitation, 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 } return items[left].ArticleID < items[right].ArticleID }) 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) }