package app import ( "context" "crypto/md5" "database/sql" "encoding/hex" "encoding/json" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" ) const ( defaultTrackingDays = 7 maxTrackingDays = 30 monitoringCollectorType = "plugin" monitoringOnlineDuration = 20 * time.Minute monitoringCollectNowQuestionLimit = 5 ) type MonitoringService struct { businessPool *pgxpool.Pool monitoringPool *pgxpool.Pool } func NewMonitoringService(businessPool, monitoringPool *pgxpool.Pool) *MonitoringService { return &MonitoringService{ businessPool: businessPool, monitoringPool: monitoringPool, } } type MonitoringDashboardCompositeResponse struct { Overview MonitoringOverview `json:"overview"` PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"` BrandTimeBuckets []MonitoringTimeBucket `json:"brand_time_buckets"` HotQuestions []MonitoringHotQuestion `json:"hot_questions"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"` } 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 MonitoringTimeBucket struct { Date string `json:"date"` SampleStatus string `json:"sample_status"` MetricValue *float64 `json:"metric_value"` 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"` PlannedSampleCount int64 `json:"planned_sample_count"` ActualSampleCount int64 `json:"actual_sample_count"` CoverageRate *float64 `json:"coverage_rate"` TriggerSource *string `json:"trigger_source"` SnapshotUpdatedAt *string `json:"snapshot_updated_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"` 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"` } type MonitoringQuestionDetailResponse struct { QuestionID int64 `json:"question_id"` QuestionHash string `json:"question_hash"` QuestionText string `json:"question_text"` 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"` } 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"` Message string `json:"message"` } type monitoringQuotaConfig struct { CollectionMode string PrimaryInstallationID *int64 EnabledPlatforms []string } 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 } 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: "deepseek", Name: "DeepSeek"}, {ID: "qwen", Name: "通义千问"}, {ID: "doubao", Name: "豆包"}, } var monitoringPlatformNames = map[string]string{ "deepseek": "DeepSeek", "qwen": "通义千问", "doubao": "豆包", "kimi": "Kimi", "yuanbao": "腾讯元宝", } func (s *MonitoringService) DashboardComposite( ctx context.Context, brandID int64, keywordID *int64, days int, businessDate string, aiPlatformID *string, ) (*MonitoringDashboardCompositeResponse, error) { actor := auth.MustActor(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.TenantID) if err != nil { return nil, err } configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID) if err != nil { return nil, err } questionIDs := configuredQuestionIDs(configuredQuestions) platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms) filteredPlatforms := filterMonitoringPlatforms(platforms, aiPlatformID) 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, aiPlatformID) if err != nil { return nil, err } overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, aiPlatformID, derivedMetrics) if err != nil { return nil, err } accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryInstallationID, 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 } timeBuckets, err := s.loadTimeBuckets(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, aiPlatformID, derivedMetrics) if err != nil { return nil, err } hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, aiPlatformID, derivedMetrics) if err != nil { return nil, err } citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, accessStates, aiPlatformID) if err != nil { return nil, err } citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, aiPlatformID) if err != nil { return nil, err } return &MonitoringDashboardCompositeResponse{ Overview: overview, PlatformBreakdown: platformBreakdown, BrandTimeBuckets: timeBuckets, HotQuestions: hotQuestions, CitationRanking: citationRanking, CitedArticles: citedArticles, }, nil } func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questionID int64, dateFrom, dateTo string, questionHash string, aiPlatformID *string) (*MonitoringQuestionDetailResponse, error) { actor := auth.MustActor(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.TenantID) if err != nil { return nil, err } platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms) 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, quota.PrimaryInstallationID, toDate) if err != nil { return nil, err } latestRuns, err := s.loadLatestPlatformRuns(ctx, actor.TenantID, brand.ID, brand.Name, questionID, hashBytes, fromDate, toDate, aiPlatformID) 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 aiPlatformID != nil && strings.TrimSpace(*aiPlatformID) != "" && platform.ID != strings.TrimSpace(*aiPlatformID) { 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], }) 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, brand.ID, questionID, hashBytes, fromDate, toDate, aiPlatformID) if err != nil { return nil, err } contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, aiPlatformID) if err != nil { return nil, err } return &MonitoringQuestionDetailResponse{ QuestionID: questionID, QuestionHash: encodeQuestionHash(hashBytes), QuestionText: questionText, 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) (*MonitoringCollectNowResponse, error) { actor := auth.MustActor(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.TenantID) 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 } platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms) questionIDs := configuredQuestionIDs(configuredQuestions) installationID, err := s.resolveCollectNowInstallationID(ctx, actor.TenantID, quota.PrimaryInstallationID) if err != nil { return nil, err } if installationID == nil { return nil, response.ErrConflict(40941, "installation_offline", "primary monitoring installation is offline") } if err := s.ensureInstallationOnline(ctx, actor.TenantID, *installationID); err != nil { return nil, err } now := time.Now().UTC() todayTime, today := monitoringBusinessDayAndDateAt(now) 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 := 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 } refreshedCount, createdCount, err := s.ensureCollectNowTasks(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, platforms, todayTime) if err != nil { return nil, err } 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) `, actor.TenantID, brand.ID, monitoringCollectorType, today, questionIDs).Scan(&leasedCount, &completedCount); err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring tasks") } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50041, "commit_failed", "failed to commit monitoring collect-now") } hasEffectiveSnapshot := false message := "已触发所选关键词立即采集,正在后台执行" switch { case createdCount > 0 && refreshedCount > 0: message = "已补种并重置所选关键词今日任务,正在后台执行" case createdCount > 0: message = "已补种所选关键词的采集任务,正在后台执行" case refreshedCount > 0: message = "已重置所选关键词今日任务,正在后台执行" case leasedCount > 0: hasEffectiveSnapshot = true message = "所选关键词已有执行中的任务,已继续在后台执行" case completedCount > 0: message = "已触发所选关键词重新采集,新的结果会覆盖今日结果" default: hasEffectiveSnapshot = true message = "所选关键词暂无可重新调度的任务" } if limitApplied && (createdCount > 0 || refreshedCount > 0) { message = fmt.Sprintf("%s(本次最多执行 %d 个问题)", message, monitoringCollectNowQuestionLimit) } return &MonitoringCollectNowResponse{ CollectionMode: quota.CollectionMode, RefreshedTaskCount: refreshedCount, CreatedTaskCount: createdCount, LeasedTaskCount: leasedCount, CompletedTaskCount: completedCount, HasEffectiveSnapshot: hasEffectiveSnapshot, Message: message, }, nil } func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context, tenantID int64, preferred *int64) (*int64, error) { if preferred != nil { online, err := s.isInstallationOnline(ctx, tenantID, *preferred) if err != nil { return nil, err } if online { return preferred, nil } } fallbackID, err := s.findLatestOnlineInstallation(ctx, tenantID) if err != nil { return nil, err } if fallbackID == nil { return nil, nil } if preferred == nil || *preferred != *fallbackID { if err := s.persistPrimaryInstallationID(ctx, tenantID, *fallbackID); err != nil { return nil, err } } return fallbackID, nil } func (s *MonitoringService) findLatestOnlineInstallation(ctx context.Context, tenantID int64) (*int64, error) { var installationID int64 err := s.businessPool.QueryRow(ctx, ` SELECT id FROM plugin_installations WHERE tenant_id = $1 AND status = 'active' AND deleted_at IS NULL AND last_seen_at IS NOT NULL AND last_seen_at >= NOW() - $2::interval ORDER BY last_seen_at DESC, updated_at DESC, id DESC LIMIT 1 `, tenantID, formatPgInterval(monitoringOnlineDuration)).Scan(&installationID) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, response.ErrInternal(50041, "query_failed", "failed to inspect online monitoring installation") } return &installationID, nil } func (s *MonitoringService) persistPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error { if _, err := s.businessPool.Exec(ctx, ` INSERT INTO tenant_monitoring_quotas ( tenant_id, primary_installation_id ) VALUES ($1, $2) ON CONFLICT (tenant_id) DO UPDATE SET primary_installation_id = EXCLUDED.primary_installation_id, updated_at = NOW() `, tenantID, installationID); err != nil { return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation") } return 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, ) (int64, int64, error) { const runMode = "plugin_standard" dateText := businessDate.Format("2006-01-02") var refreshedCount int64 var createdCount int64 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', 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) if err != nil { return 0, 0, response.ErrInternal(50041, "update_failed", "failed to refresh monitoring tasks") } refreshedCount += tag.RowsAffected() 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 ) VALUES ( $1, $2, $3, $4, $5, $6, 'manual', $7, $8::date, NOW(), 'pending' ) 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) if err != nil { return 0, 0, response.ErrInternal(50041, "insert_failed", "failed to create monitoring tasks") } createdCount += tag.RowsAffected() } } return refreshedCount, createdCount, nil } func (s *MonitoringService) loadQuota(ctx context.Context, tenantID int64) (monitoringQuotaConfig, error) { cfg := monitoringQuotaConfig{ CollectionMode: monitoringCollectorType, EnabledPlatforms: []string{"deepseek", "qwen", "doubao"}, } var enabledJSON []byte var installationID sql.NullInt64 err := s.businessPool.QueryRow(ctx, ` SELECT collection_mode, enabled_platforms, primary_installation_id FROM tenant_monitoring_quotas WHERE tenant_id = $1 `, tenantID).Scan(&cfg.CollectionMode, &enabledJSON, &installationID) if err != nil { if err == pgx.ErrNoRows { return cfg, nil } return cfg, response.ErrInternal(50041, "query_failed", "failed to load monitoring quota") } if len(enabledJSON) > 0 { var enabled []string if jsonErr := json.Unmarshal(enabledJSON, &enabled); jsonErr == nil && len(enabled) > 0 { cfg.EnabledPlatforms = enabled } } if installationID.Valid { id := installationID.Int64 cfg.PrimaryInstallationID = &id } if strings.TrimSpace(cfg.CollectionMode) == "" { cfg.CollectionMode = monitoringCollectorType } return cfg, 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 averageDerivedRateByPlatform( statsByPlatform map[string]monitoringDerivedRateStats, extractor func(monitoringDerivedRateStats) *float64, ) *float64 { if len(statsByPlatform) == 0 { return nil } total := 0.0 count := 0 for _, stats := range statsByPlatform { value := extractor(stats) if value == nil { continue } total += *value count++ } if count == 0 { return nil } average := total / float64(count) return &average } 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() 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 = $6) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableString(aiPlatformID)) 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") } 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 := normalizedOptionalString(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 current, found, err := s.loadPlatformOverviewSnapshotForDate(ctx, tenantID, brandID, aiPlatformID, 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, aiPlatformID, 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, aiPlatformID string, collectionMode string, businessDate time.Time, ) (monitoringOverviewSnapshot, bool, error) { var item monitoringOverviewSnapshot err := s.monitoringPool.QueryRow(ctx, ` SELECT business_date, planned_sample_count, actual_sample_count, mention_rate::double precision, top1_mention_rate::double precision, last_sampled_at FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2 AND ai_platform_id = $3 AND collector_type = $4 AND business_date = $5::date `, tenantID, brandID, aiPlatformID, 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, aiPlatformID 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, planned_sample_count, actual_sample_count, mention_rate::double precision, top1_mention_rate::double precision, last_sampled_at FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2 AND ai_platform_id = $3 AND collector_type = $4 AND business_date BETWEEN $5::date AND $6::date AND actual_sample_count > 0 ORDER BY business_date DESC LIMIT 1 `, tenantID, brandID, aiPlatformID, 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 currentPlatformStats, ok := derived.ByDatePlatform[dateKey]; ok { snapshot.MentionRate = pointerFloat64ToNull(averageDerivedRateByPlatform(currentPlatformStats, func(stats monitoringDerivedRateStats) *float64 { return stats.mentionRate() })) } if stats, ok := derived.ByDate[dateKey]; ok && stats.hasSamples() { snapshot.Top1MentionRate = pointerFloat64ToNull(stats.top1MentionRate()) snapshot.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate()) snapshot.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate()) } } func (s *MonitoringService) loadTimeBuckets( ctx context.Context, tenantID, brandID int64, startDate, endDate time.Time, collectionMode string, aiPlatformID *string, derived monitoringDerivedMetrics, ) ([]MonitoringTimeBucket, error) { if platformID := normalizedOptionalString(aiPlatformID); platformID != "" { return s.loadTimeBucketsForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived) } rows, err := s.monitoringPool.Query(ctx, ` SELECT business_date, sample_status, mention_rate::double precision, top1_mention_rate::double precision, first_recommend_rate::double precision, positive_mention_rate::double precision, citation_rate::double precision, planned_sample_count, actual_sample_count, coverage_rate::double precision, trigger_source, snapshot_updated_at 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 ORDER BY business_date ASC `, tenantID, brandID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring time buckets") } defer rows.Close() type bucketRow struct { Date time.Time SampleStatus string MentionRate sql.NullFloat64 Top1MentionRate sql.NullFloat64 FirstRecommendRate sql.NullFloat64 PositiveMentionRate sql.NullFloat64 CitationRate sql.NullFloat64 PlannedSampleCount int64 ActualSampleCount int64 CoverageRate sql.NullFloat64 TriggerSource sql.NullString SnapshotUpdatedAt sql.NullTime } bucketMap := make(map[string]bucketRow) for rows.Next() { var item bucketRow if scanErr := rows.Scan( &item.Date, &item.SampleStatus, &item.MentionRate, &item.Top1MentionRate, &item.FirstRecommendRate, &item.PositiveMentionRate, &item.CitationRate, &item.PlannedSampleCount, &item.ActualSampleCount, &item.CoverageRate, &item.TriggerSource, &item.SnapshotUpdatedAt, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring time buckets") } bucketMap[item.Date.Format("2006-01-02")] = item } buckets := make([]MonitoringTimeBucket, 0, int(endDate.Sub(startDate).Hours()/24)+1) for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) { key := cursor.Format("2006-01-02") if item, ok := bucketMap[key]; ok { if platformStats, ok := derived.ByDatePlatform[key]; ok { item.MentionRate = pointerFloat64ToNull(averageDerivedRateByPlatform(platformStats, func(stats monitoringDerivedRateStats) *float64 { return stats.mentionRate() })) } if stats, ok := derived.ByDate[key]; ok && stats.hasSamples() { item.Top1MentionRate = pointerFloat64ToNull(stats.top1MentionRate()) item.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate()) item.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate()) } buckets = append(buckets, MonitoringTimeBucket{ Date: key, SampleStatus: item.SampleStatus, MetricValue: floatPointer(item.MentionRate), MentionRate: floatPointer(item.MentionRate), Top1MentionRate: floatPointer(item.Top1MentionRate), FirstRecommendRate: floatPointer(item.FirstRecommendRate), PositiveMentionRate: floatPointer(item.PositiveMentionRate), CitationRate: floatPointer(item.CitationRate), PlannedSampleCount: item.PlannedSampleCount, ActualSampleCount: item.ActualSampleCount, CoverageRate: floatPointer(item.CoverageRate), TriggerSource: stringPointer(item.TriggerSource), SnapshotUpdatedAt: formatNullTime(item.SnapshotUpdatedAt), }) continue } buckets = append(buckets, MonitoringTimeBucket{ Date: key, SampleStatus: "unsampled", }) } return buckets, nil } func (s *MonitoringService) loadTimeBucketsForPlatform( ctx context.Context, tenantID, brandID int64, startDate, endDate time.Time, collectionMode string, aiPlatformID string, derived monitoringDerivedMetrics, ) ([]MonitoringTimeBucket, error) { rows, err := s.monitoringPool.Query(ctx, ` SELECT business_date, platform_sample_status, mention_rate::double precision, top1_mention_rate::double precision, planned_sample_count, actual_sample_count, last_sampled_at FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2 AND ai_platform_id = $3 AND collector_type = $4 AND business_date BETWEEN $5::date AND $6::date ORDER BY business_date ASC `, tenantID, brandID, aiPlatformID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform time buckets") } defer rows.Close() type bucketRow struct { Date time.Time SampleStatus string MentionRate sql.NullFloat64 Top1MentionRate sql.NullFloat64 FirstRecommendRate sql.NullFloat64 PositiveMentionRate sql.NullFloat64 PlannedSampleCount int64 ActualSampleCount int64 SnapshotUpdatedAt sql.NullTime } bucketMap := make(map[string]bucketRow) for rows.Next() { var item bucketRow if scanErr := rows.Scan( &item.Date, &item.SampleStatus, &item.MentionRate, &item.Top1MentionRate, &item.PlannedSampleCount, &item.ActualSampleCount, &item.SnapshotUpdatedAt, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring platform time buckets") } bucketMap[item.Date.Format("2006-01-02")] = item } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring platform time buckets") } buckets := make([]MonitoringTimeBucket, 0, int(endDate.Sub(startDate).Hours()/24)+1) for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) { key := cursor.Format("2006-01-02") if item, ok := bucketMap[key]; ok { if platformStatsByDate, ok := derived.ByDatePlatform[key]; ok { if platformStats, ok := platformStatsByDate[aiPlatformID]; ok && platformStats.hasSamples() { item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate()) item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate()) } } if stats, ok := derived.ByDate[key]; ok && stats.hasSamples() { item.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate()) item.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate()) } coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount) buckets = append(buckets, MonitoringTimeBucket{ Date: key, SampleStatus: item.SampleStatus, MetricValue: floatPointer(item.MentionRate), MentionRate: floatPointer(item.MentionRate), Top1MentionRate: floatPointer(item.Top1MentionRate), FirstRecommendRate: floatPointer(item.FirstRecommendRate), PositiveMentionRate: floatPointer(item.PositiveMentionRate), CitationRate: nil, PlannedSampleCount: item.PlannedSampleCount, ActualSampleCount: item.ActualSampleCount, CoverageRate: coverageRate, TriggerSource: nil, SnapshotUpdatedAt: formatNullTime(item.SnapshotUpdatedAt), }) continue } buckets = append(buckets, MonitoringTimeBucket{ Date: key, SampleStatus: "unsampled", }) } return buckets, nil } func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64, installationID *int64, businessDate time.Time) (map[string]monitoringAccessState, error) { states := map[string]monitoringAccessState{} if installationID == nil { 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 installation_id = $2 AND business_date = $3::date `, tenantID, *installationID, 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") } states[platformID] = monitoringAccessState{AccessStatus: accessStatus} } return states, 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") } 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") 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 = $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 = $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, nullableString(aiPlatformID)) 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 } rows, err := s.monitoringPool.Query(ctx, ` WITH run_counts AS ( SELECT r.ai_platform_id, COUNT(*) AS sample_count 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 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 = $7) GROUP BY r.ai_platform_id ), citation_counts AS ( SELECT r.ai_platform_id, COUNT(DISTINCT cf.run_id) FILTER (WHERE cf.article_id IS NOT NULL) AS cited_answer_count, COUNT(DISTINCT cf.article_id) FILTER (WHERE cf.article_id IS NOT NULL) AS cited_article_count 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.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 = $7) GROUP BY r.ai_platform_id ) SELECT rc.ai_platform_id, COALESCE(cc.cited_answer_count, 0) AS cited_answer_count, COALESCE(cc.cited_article_count, 0) AS cited_article_count, CASE WHEN rc.sample_count > 0 THEN COALESCE(cc.cited_answer_count, 0)::double precision / rc.sample_count ELSE NULL END AS citation_rate FROM run_counts rc LEFT JOIN citation_counts cc ON cc.ai_platform_id = rc.ai_platform_id WHERE COALESCE(cc.cited_answer_count, 0) > 0 ORDER BY cited_answer_count DESC, cited_article_count DESC, rc.ai_platform_id ASC `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") } defer rows.Close() items := make([]MonitoringCitationRanking, 0) for rows.Next() { var platformID string var citedAnswerCount int64 var citedArticleCount int64 var citationRate sql.NullFloat64 if scanErr := rows.Scan(&platformID, &citedAnswerCount, &citedArticleCount, &citationRate); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") } items = append(items, MonitoringCitationRanking{ AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), SampleStatus: derivePlatformSampleStatus("", accessStates[platformID]), CitedAnswerCount: citedAnswerCount, CitedArticleCount: citedArticleCount, CitationRate: floatPointer(citationRate), }) } 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 } var totalSampleCount int64 if err := s.monitoringPool.QueryRow(ctx, ` SELECT COUNT(*) 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 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 = $7) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID)).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.article_id, COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title, COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform, COUNT(*) AS citation_count FROM monitoring_citation_facts cf JOIN question_monitor_runs r ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id LEFT JOIN monitoring_article_url_aliases alias ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id WHERE cf.tenant_id = $1 AND cf.brand_id = $2 AND cf.article_id IS NOT NULL AND r.collector_type = $3 AND r.status = 'succeeded' AND r.business_date BETWEEN $4::date AND $5::date AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($7::text IS NULL OR r.ai_platform_id = $7) GROUP BY cf.article_id ORDER BY citation_count DESC, cf.article_id ASC LIMIT 10 `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles") } defer rows.Close() items := make([]MonitoringCitedArticle, 0) for rows.Next() { var articleID int64 var title string var publishPlatform string var citationCount int64 if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") } items = append(items, MonitoringCitedArticle{ ArticleID: articleID, ArticleTitle: title, PublishPlatform: publishPlatform, CitationCount: citationCount, CitationRate: divideAsPointer(citationCount, totalSampleCount), }) } 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) { 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, 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 = $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"), nullableString(aiPlatformID)) 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 if scanErr := rows.Scan( &item.PlatformID, &item.RunID, &item.CompletedAt, &item.ProviderModel, &item.AnswerText, &item.BrandMentioned, &item.BrandMentionPosition, ); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse latest question runs") } 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)) items[item.PlatformID] = item } return items, nil } 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 } rows, err := s.monitoringPool.Query(ctx, ` SELECT cf.run_id, cf.cited_url, cf.cited_title, COALESCE(tm.site_name, gm.site_name, cf.site_key, cf.registrable_domain) AS site_name, COALESCE(tm.site_key, gm.site_key, cf.site_key) AS site_key, cf.article_id, alias.article_title_snapshot AS article_title, cf.resolution_status, cf.resolution_confidence FROM monitoring_citation_facts cf LEFT JOIN LATERAL ( SELECT site_key, site_name FROM site_domain_mappings WHERE tenant_id = cf.tenant_id AND is_active = TRUE AND ( registrable_domain = cf.registrable_domain OR registrable_domain = cf.site_key OR cf.host = registrable_domain OR cf.host LIKE '%.' || registrable_domain ) ORDER BY LENGTH(registrable_domain) DESC, id DESC LIMIT 1 ) tm ON TRUE LEFT JOIN LATERAL ( SELECT site_key, site_name FROM site_domain_mappings WHERE tenant_id IS NULL AND is_active = TRUE AND ( registrable_domain = cf.registrable_domain OR registrable_domain = cf.site_key OR cf.host = registrable_domain OR cf.host LIKE '%.' || registrable_domain ) ORDER BY LENGTH(registrable_domain) DESC, id DESC LIMIT 1 ) gm ON tm.site_key IS NULL LEFT JOIN monitoring_article_url_aliases alias ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id WHERE cf.tenant_id = $1 AND cf.run_id = ANY($2) ORDER BY cf.run_id ASC, cf.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 siteName string var siteKey string var articleID sql.NullInt64 var articleTitle sql.NullString var resolutionStatus string var resolutionConfidence string if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &siteName, &siteKey, &articleID, &articleTitle, &resolutionStatus, &resolutionConfidence); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations") } result[runID] = append(result[runID], MonitoringQuestionDetailCitation{ CitedURL: citedURL, CitedTitle: nullableStringValue(citedTitle), SiteName: siteName, SiteKey: siteKey, FaviconURL: faviconURL(siteKey), ArticleID: nullableInt64Value(articleID), ArticleTitle: nullableStringValue(articleTitle), ResolutionStatus: resolutionStatus, ResolutionConfidence: resolutionConfidence, }) } return result, nil } func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionCitationStats, error) { rows, err := s.monitoringPool.Query(ctx, ` WITH grouped AS ( SELECT r.ai_platform_id, COALESCE(tm.site_name, gm.site_name, cf.site_key, cf.registrable_domain) AS site_name, COALESCE(tm.site_key, gm.site_key, cf.site_key) AS site_key, COALESCE(tm.registrable_domain, gm.registrable_domain, cf.site_key, cf.registrable_domain) AS site_domain, COUNT(cf.id) AS citation_count, COUNT(*) FILTER (WHERE cf.article_id IS NOT NULL) AS content_citation_count FROM question_monitor_runs r JOIN monitoring_citation_facts cf ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id LEFT JOIN LATERAL ( SELECT registrable_domain, site_key, site_name FROM site_domain_mappings WHERE tenant_id = cf.tenant_id AND is_active = TRUE AND ( registrable_domain = cf.registrable_domain OR registrable_domain = cf.site_key OR cf.host = registrable_domain OR cf.host LIKE '%.' || registrable_domain ) ORDER BY LENGTH(registrable_domain) DESC, id DESC LIMIT 1 ) tm ON TRUE LEFT JOIN LATERAL ( SELECT registrable_domain, site_key, site_name FROM site_domain_mappings WHERE tenant_id IS NULL AND is_active = TRUE AND ( registrable_domain = cf.registrable_domain OR registrable_domain = cf.site_key OR cf.host = registrable_domain OR cf.host LIKE '%.' || registrable_domain ) ORDER BY LENGTH(registrable_domain) DESC, id DESC LIMIT 1 ) gm ON tm.site_key IS NULL 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 = $8) GROUP BY r.ai_platform_id, COALESCE(tm.site_name, gm.site_name, cf.site_key, cf.registrable_domain), COALESCE(tm.site_key, gm.site_key, cf.site_key), COALESCE(tm.registrable_domain, gm.registrable_domain, cf.site_key, cf.registrable_domain) ), totals AS ( SELECT ai_platform_id, SUM(citation_count) AS total_citation_count FROM grouped GROUP BY ai_platform_id ) SELECT g.ai_platform_id, g.site_name, g.site_key, g.site_domain, g.citation_count, CASE WHEN t.total_citation_count > 0 THEN g.citation_count::double precision / t.total_citation_count::double precision ELSE NULL END AS citation_rate, g.content_citation_count FROM grouped g JOIN totals t ON t.ai_platform_id = g.ai_platform_id ORDER BY g.ai_platform_id ASC, g.citation_count DESC, g.site_name ASC `, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableString(aiPlatformID)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis") } defer rows.Close() items := make([]MonitoringQuestionCitationStats, 0) for rows.Next() { var platformID string var siteName string var siteKey string var siteDomain string var citationCount int64 var citationRate sql.NullFloat64 var contentCitationCount int64 if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, &citationRate, &contentCitationCount); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis") } items = append(items, MonitoringQuestionCitationStats{ AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), SiteName: siteName, SiteKey: siteKey, SiteDomain: siteDomain, CitationCount: citationCount, CitationRate: floatPointer(citationRate), ContentCitationCount: contentCitationCount, }) } return items, nil } func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionContentCitation, error) { rows, err := s.monitoringPool.Query(ctx, ` WITH platform_totals AS ( SELECT r.ai_platform_id, COUNT(cf.id) AS total_citation_count 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.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 = $8) GROUP BY r.ai_platform_id ) SELECT cf.article_id, COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title, COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform, r.ai_platform_id, COUNT(*) AS citation_count, MAX(pt.total_citation_count) AS total_citation_count FROM question_monitor_runs r JOIN monitoring_citation_facts cf ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id JOIN platform_totals pt ON pt.ai_platform_id = r.ai_platform_id LEFT JOIN monitoring_article_url_aliases alias ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_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 cf.article_id IS NOT NULL AND ($8::text IS NULL OR r.ai_platform_id = $8) GROUP BY cf.article_id, r.ai_platform_id ORDER BY citation_count DESC, cf.article_id ASC `, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableString(aiPlatformID)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations") } defer rows.Close() items := make([]MonitoringQuestionContentCitation, 0) for rows.Next() { var articleID int64 var articleTitle string var publishPlatform string var platformID string var citationCount int64 var totalCitationCount int64 if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations") } items = append(items, MonitoringQuestionContentCitation{ ArticleID: articleID, ArticleTitle: articleTitle, PublishPlatform: publishPlatform, AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), CitationCount: citationCount, CitationRate: divideAsPointer(citationCount, totalCitationCount), }) } return items, nil } func (s *MonitoringService) ensureInstallationOnline(ctx context.Context, tenantID, installationID int64) error { online, err := s.isInstallationOnline(ctx, tenantID, installationID) if err != nil { return err } if !online { return response.ErrConflict(40941, "installation_offline", "primary monitoring installation is offline") } return nil } func (s *MonitoringService) isInstallationOnline(ctx context.Context, tenantID, installationID int64) (bool, error) { var lastSeenAt sql.NullTime err := s.businessPool.QueryRow(ctx, ` SELECT last_seen_at FROM plugin_installations WHERE id = $1 AND tenant_id = $2 AND status = 'active' AND deleted_at IS NULL `, installationID, tenantID).Scan(&lastSeenAt) if err != nil { if err == pgx.ErrNoRows { return false, nil } return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring installation") } if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > monitoringOnlineDuration { 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 resolveMonitoringPlatforms(enabled []string) []monitoringPlatformMetadata { if len(enabled) == 0 { return defaultMonitoringPlatforms } platforms := make([]monitoringPlatformMetadata, 0, len(enabled)) seen := make(map[string]struct{}, len(enabled)) for _, item := range enabled { id := strings.TrimSpace(item) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} platforms = append(platforms, monitoringPlatformMetadata{ ID: id, Name: platformDisplayName(id), }) } if len(platforms) == 0 { return defaultMonitoringPlatforms } return platforms } func filterMonitoringPlatforms(platforms []monitoringPlatformMetadata, aiPlatformID *string) []monitoringPlatformMetadata { platformID := normalizedOptionalString(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 []monitoringPlatformMetadata{ { ID: platformID, Name: platformDisplayName(platformID), }, } } func platformDisplayName(platformID string) string { 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 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 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) }