diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 4d8e3f8..b489646 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -432,7 +432,10 @@ const enUS = { backToQuestions: "Back to questions", citationRanking: "Citation Ranking", citationRankingTitle: "Citation Ranking", - citationRankingHint: "Track how often content published from this system appears across model answers.", + citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.", + citationWindow7: "Last 7 days", + citationWindow30: "Last 30 days", + citationWindowLabel: "Last {days} days", citedArticles: "Cited Articles", citedArticlesTitle: "Cited Articles", answerContent: "Answer Content", @@ -492,6 +495,7 @@ const enUS = { citedAnswers: "Cited Answers", citedArticles: "Cited Articles", citationRate: "Citation Rate", + saasSourceRate: "Source Share", citations: "Citations", sourcePlatform: "Source", channelName: "Channel", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index a12de65..f08c915 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -433,7 +433,10 @@ const zhCN = { backToQuestions: "返回问题列表", citationRanking: "Citation Ranking", citationRankingTitle: "引用排行", - citationRankingHint: "统计系统发布内容在各模型回答中出现的次数与覆盖率。", + citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。", + citationWindow7: "近 7 天", + citationWindow30: "近 30 天", + citationWindowLabel: "近 {days} 天", citedArticles: "Cited Articles", citedArticlesTitle: "被引用文章", answerContent: "答案内容", @@ -493,6 +496,7 @@ const zhCN = { citedAnswers: "引用回答数", citedArticles: "引用文章数", citationRate: "引用率", + saasSourceRate: "来源占比", citations: "引用次数", sourcePlatform: "来源平台", channelName: "渠道名称", diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 8179a16..1d49927 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -57,6 +57,7 @@ import type { LoginResponse, MediaPlatform, MonitoringCollectNowResponse, + MonitoringCitationSummaryResponse, MonitoringDashboardCompositeResponse, MonitoringQuestionDetailResponse, PlatformAccount, @@ -1075,6 +1076,13 @@ export const monitoringApi = { params, }); }, + citationSummary(params: { + days?: number; + }) { + return apiClient.get("/api/tenant/monitoring/citation-summary", { + params, + }); + }, questionDetail( brandId: number, questionId: number, diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue index 3bfb346..a7ed47a 100644 --- a/apps/admin-web/src/views/TrackingView.vue +++ b/apps/admin-web/src/views/TrackingView.vue @@ -37,6 +37,9 @@ type CitationRankingRow = { platform_name: string; cited_answer_count: number | null; cited_article_count: number | null; + citation_source_count: number | null; + saas_source_count: number | null; + saas_source_rate: number | null; citation_rate: number | null; share: number; color: string; @@ -95,9 +98,11 @@ const selectedBrandId = useStorage("tracking_selected_brand", nul const selectedKeywordId = useStorage("tracking_selected_keyword", null); const selectedPlatformId = useStorage("tracking_selected_ai_platform_id", "all"); const selectedBusinessDate = useStorage("tracking_selected_business_date", trackingToday); +const selectedCitationWindowDays = useStorage("tracking_selected_citation_window_days", 7); selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value); selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday; +selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value); const brandsQuery = useQuery({ queryKey: ["tracking", "brands"], @@ -188,12 +193,14 @@ watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessD const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined; const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id); const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday; + const hasLegacyCitationDaysQuery = normalizeQueryValue(route.query.citation_days) !== ""; if ( currentBrandId === nextBrandId && currentKeywordId === nextKeywordId && currentPlatformId === (nextPlatformId ?? "all") && - currentBusinessDate === nextBusinessDate + currentBusinessDate === nextBusinessDate && + !hasLegacyCitationDaysQuery ) { return; } @@ -240,6 +247,18 @@ const dashboardQuery = useQuery({ }), }); +const citationSummaryQuery = useQuery({ + queryKey: computed(() => [ + "tracking", + "citation-summary", + selectedCitationWindowDays.value, + ]), + queryFn: () => + monitoringApi.citationSummary({ + days: selectedCitationWindowDays.value, + }), +}); + const aiAccountsQuery = useQuery({ queryKey: ["tracking", "ai-platform-accounts"], queryFn: () => tenantAccountsApi.list(), @@ -405,34 +424,37 @@ const selectedMetricCards = computed(() => { ]; }); -const citationRankingRows = computed(() => { - if (!selectedDateHasData.value) { - return []; - } +const citationWindowOptions = computed(() => [ + { label: t("tracking.citationWindow7"), value: 7 }, + { label: t("tracking.citationWindow30"), value: 30 }, +]); - const breakdown = dashboardQuery.data.value?.platform_breakdown ?? []; - const ranking = dashboardQuery.data.value?.citation_ranking ?? []; +const citationRankingRows = computed(() => { + const ranking = citationSummaryQuery.data.value?.citation_ranking ?? []; const order = new Map(); const rows = new Map(); - breakdown.forEach((item, index) => { - const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id; + aiPlatformCatalog.forEach((item, index) => { + const platformId = normalizeMonitoringPlatformId(item.id) || item.id; order.set(platformId, index); rows.set(platformId, { ai_platform_id: platformId, - platform_name: item.platform_name, + platform_name: item.label, cited_answer_count: null, cited_article_count: null, + citation_source_count: null, + saas_source_count: null, + saas_source_rate: null, citation_rate: null, share: 0, - ...platformAppearance(platformId, item.platform_name), + ...platformAppearance(platformId, item.label), }); }); ranking.forEach((item, index) => { const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id; if (!order.has(platformId)) { - order.set(platformId, breakdown.length + index); + order.set(platformId, aiPlatformCatalog.length + index); } const existing = rows.get(platformId); @@ -441,6 +463,9 @@ const citationRankingRows = computed(() => { platform_name: item.platform_name || existing?.platform_name || platformId, cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0), cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0), + citation_source_count: (existing?.citation_source_count ?? 0) + (item.citation_source_count ?? 0), + saas_source_count: (existing?.saas_source_count ?? 0) + (item.saas_source_count ?? 0), + saas_source_rate: item.saas_source_rate ?? existing?.saas_source_rate ?? null, citation_rate: item.citation_rate ?? existing?.citation_rate ?? null, share: 0, ...platformAppearance(platformId, item.platform_name || existing?.platform_name || platformId), @@ -448,16 +473,16 @@ const citationRankingRows = computed(() => { }); const list = Array.from(rows.values()); - const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.cited_answer_count ?? 0, 0), 0); + const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0), 0); const enriched = list.map((item) => ({ ...item, - share: totalAppearances > 0 ? Math.max(item.cited_answer_count ?? 0, 0) / totalAppearances : 0, + share: totalAppearances > 0 ? Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0) / totalAppearances : 0, })); - const hasCitationData = enriched.some((item) => (item.cited_answer_count ?? 0) > 0); + const hasCitationData = enriched.some((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0); return enriched.sort((left, right) => { if (hasCitationData) { - const countDiff = (right.cited_answer_count ?? 0) - (left.cited_answer_count ?? 0); + const countDiff = (right.saas_source_count ?? right.cited_answer_count ?? 0) - (left.saas_source_count ?? left.cited_answer_count ?? 0); if (countDiff !== 0) { return countDiff; } @@ -467,14 +492,11 @@ const citationRankingRows = computed(() => { }); const visibleCitedArticles = computed(() => { - if (!selectedDateHasData.value) { - return []; - } - return dashboardQuery.data.value?.cited_articles ?? []; + return citationSummaryQuery.data.value?.cited_articles ?? []; }); const citationChartStyle = computed(() => { - const segments = citationRankingRows.value.filter((item) => (item.cited_answer_count ?? 0) > 0); + const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0); if (!segments.length) { return { @@ -533,6 +555,11 @@ function formatPercent(value: number | null | undefined): string { return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`; } +function normalizeCitationWindowDays(value: unknown): number { + const numeric = Number(value); + return numeric === 30 ? 30 : 7; +} + function accountAuthLabel(account: DesktopAccountInfo | null): string { if (!account) { return "未绑定"; @@ -866,11 +893,27 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
- +
-

{{ t("tracking.citationRankingTitle") }}

-

{{ t("tracking.citationRankingHint") }}

+
+

{{ t("tracking.citationRankingTitle") }}

+

{{ t("tracking.citationRankingHint") }}

+
+ + + {{ item.label }} + +
@@ -908,6 +951,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
{{ t("tracking.columns.platform") }} {{ t("tracking.columns.appearances") }} + {{ t("tracking.columns.saasSourceRate") }} {{ t("tracking.columns.citationRate") }}
@@ -925,7 +969,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { {{ item.platform_name }}
- {{ formatCount(item.cited_answer_count) }} {{ item.cited_answer_count != null ? '条' : '' }} + + {{ formatCount(item.saas_source_count ?? item.cited_answer_count) }} + {{ item.saas_source_count != null ? t("tracking.citations") : "" }} + + {{ formatPercent(item.saas_source_rate) }} {{ formatPercent(item.citation_rate) }}
@@ -966,9 +1014,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
- +

{{ t("tracking.citedArticlesTitle") }}

+ {{ t("tracking.citationWindowLabel", { days: selectedCitationWindowDays }) }}
@@ -983,6 +1032,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
{{ item.citation_count }} {{ t("tracking.citations") }} + {{ t("tracking.columns.share") }} {{ formatPercent(item.source_share) }} {{ formatPercent(item.citation_rate) }}
@@ -1152,6 +1202,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { } .tracking-citation-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; margin-bottom: 24px; } @@ -1177,6 +1231,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { overflow: hidden; } +.citation-window-switch { + flex: 0 0 auto; +} + .tracking-panel__header { display: flex; align-items: center; @@ -1398,10 +1456,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { .citation-ranking-sheet__head, .citation-ranking-sheet__row { display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr); + grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.72fr) minmax(0, 0.72fr) minmax(0, 0.72fr); align-items: center; gap: 16px; padding: 20px 32px; + min-width: 560px; } .citation-ranking-sheet__head { @@ -1580,9 +1639,13 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { } @media (max-width: 640px) { + .tracking-citation-header { + flex-direction: column; + } + .citation-ranking-sheet__head, .citation-ranking-sheet__row { - grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr); + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.7fr); gap: 10px; font-size: 13px; } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ad4d4f0..e81da90 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1191,6 +1191,9 @@ export interface MonitoringCitationRankingItem { sample_status: string; cited_answer_count: number; cited_article_count: number; + citation_source_count: number; + saas_source_count: number; + saas_source_rate: number | null; citation_rate: number | null; } @@ -1200,6 +1203,13 @@ export interface MonitoringCitedArticle { publish_platform: string; citation_count: number; citation_rate: number | null; + source_share: number | null; +} + +export interface MonitoringDashboardCitationWindow { + days: number; + from: string; + to: string; } export type MonitoringPlatformAuthorizationStatus = @@ -1217,12 +1227,19 @@ export interface MonitoringDashboardRuntime { export interface MonitoringDashboardCompositeResponse { overview: MonitoringOverview; runtime: MonitoringDashboardRuntime; + citation_window: MonitoringDashboardCitationWindow; platform_breakdown: MonitoringPlatformBreakdownItem[]; hot_questions: MonitoringHotQuestion[]; citation_ranking: MonitoringCitationRankingItem[]; cited_articles: MonitoringCitedArticle[]; } +export interface MonitoringCitationSummaryResponse { + citation_window: MonitoringDashboardCitationWindow; + citation_ranking: MonitoringCitationRankingItem[]; + cited_articles: MonitoringCitedArticle[]; +} + export interface MonitoringQuestionDetailCitation { cited_url: string; cited_title: string | null; diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go index 18681f4..a3e1505 100644 --- a/server/internal/tenant/app/desktop_task_service.go +++ b/server/internal/tenant/app/desktop_task_service.go @@ -590,6 +590,9 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De if publishOutcome != nil { invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID) + if syncErr := s.syncMonitoringArticleAlias(ctx, publishOutcome.ArticleAlias); syncErr != nil { + s.logWarn("monitoring article alias sync failed after publish complete", syncErr, zap.Int64("article_id", publishOutcome.ArticleID)) + } } s.publishTaskEvent(ctx, task, "task_completed") @@ -733,6 +736,9 @@ func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, de if publishOutcome != nil { invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID) + if syncErr := s.syncMonitoringArticleAlias(ctx, publishOutcome.ArticleAlias); syncErr != nil { + s.logWarn("monitoring article alias sync failed after publish reconcile", syncErr, zap.Int64("article_id", publishOutcome.ArticleID)) + } } eventType := "task_reconciled" diff --git a/server/internal/tenant/app/monitoring_article_alias_sync.go b/server/internal/tenant/app/monitoring_article_alias_sync.go new file mode 100644 index 0000000..2807438 --- /dev/null +++ b/server/internal/tenant/app/monitoring_article_alias_sync.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "net/url" + "strings" +) + +func (s *DesktopTaskService) syncMonitoringArticleAlias(ctx context.Context, input *monitoringArticleAliasInput) error { + if s == nil || s.monitoringPool == nil || input == nil { + return nil + } + + originalURL, confidence := monitoringAliasOriginalURL(input) + if originalURL == "" { + return nil + } + + normalizedURL := normalizeCitationURL(originalURL) + if normalizedURL == "" { + return nil + } + host, registrableDomain, _, _ := deriveCitationURLParts(originalURL, normalizedURL) + if registrableDomain == "" { + registrableDomain = host + } + siteKey := host + if siteKey == "" { + siteKey = registrableDomain + } + lastPathSegment := monitoringAliasLastPathSegment(normalizedURL) + + _, err := s.monitoringPool.Exec(ctx, ` + INSERT INTO monitoring_article_url_aliases ( + tenant_id, article_id, publish_record_id, platform_id, publish_platform_name_snapshot, + external_article_id, article_title_snapshot, original_url, normalized_url, + host, registrable_domain, site_key, last_path_segment, confidence + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13, $14 + ) + ON CONFLICT (tenant_id, normalized_url) + DO UPDATE SET + article_id = EXCLUDED.article_id, + publish_record_id = EXCLUDED.publish_record_id, + platform_id = EXCLUDED.platform_id, + publish_platform_name_snapshot = EXCLUDED.publish_platform_name_snapshot, + external_article_id = EXCLUDED.external_article_id, + article_title_snapshot = EXCLUDED.article_title_snapshot, + original_url = EXCLUDED.original_url, + host = EXCLUDED.host, + registrable_domain = EXCLUDED.registrable_domain, + site_key = EXCLUDED.site_key, + last_path_segment = EXCLUDED.last_path_segment, + confidence = EXCLUDED.confidence, + updated_at = NOW() + `, + input.TenantID, + input.ArticleID, + input.PublishRecordID, + strings.TrimSpace(input.PlatformID), + strings.TrimSpace(input.PlatformName), + normalizeStringPointer(input.ExternalArticleID), + strings.TrimSpace(input.ArticleTitle), + originalURL, + normalizedURL, + host, + registrableDomain, + siteKey, + nullableString(lastPathSegment), + confidence, + ) + return err +} + +func monitoringAliasOriginalURL(input *monitoringArticleAliasInput) (string, string) { + if input == nil { + return "", "" + } + if strings.TrimSpace(input.PlatformID) == "baijiahao" { + if articleID := strings.TrimSpace(monitoringStringValue(input.ExternalArticleID)); articleID != "" { + return baijiahaoPublishedArticleURL(articleID), "high" + } + } + if value := strings.TrimSpace(monitoringStringValue(input.ExternalArticleURL)); value != "" { + return value, "high" + } + if value := strings.TrimSpace(monitoringStringValue(input.ExternalManageURL)); value != "" { + return value, "medium" + } + return "", "" +} + +func monitoringAliasLastPathSegment(rawURL string) *string { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil + } + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for index := len(segments) - 1; index >= 0; index-- { + if segment := strings.TrimSpace(segments[index]); segment != "" { + return &segment + } + } + return nil +} diff --git a/server/internal/tenant/app/monitoring_callback_service.go b/server/internal/tenant/app/monitoring_callback_service.go index eaabd0e..b7a566b 100644 --- a/server/internal/tenant/app/monitoring_callback_service.go +++ b/server/internal/tenant/app/monitoring_callback_service.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -274,9 +275,31 @@ type monitoringLeasedTaskRow struct { } type monitoringAliasResolution struct { - ArticleID *int64 - PublishRecordID *int64 - NormalizedURLKey string + ArticleID *int64 + PublishRecordID *int64 + NormalizedURLKey string + ResolutionStatus string + ResolutionConfidence string +} + +type monitoringAliasLookupInput struct { + NormalizedURL string + RegistrableDomain string + SiteKey string + Host string + LastPathSegment *string + TitleKey string +} + +type monitoringAliasCandidate struct { + NormalizedURL string + ArticleID *int64 + PublishRecordID *int64 + RegistrableDomain string + SiteKey string + Host string + LastPathSegment string + TitleKey string } type monitoringCitationFact struct { @@ -2400,7 +2423,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p resolveContentCitations := monitoringPlatformResolvesContentCitations(platformID) aliasMap := map[string]monitoringAliasResolution{} if resolveContentCitations { - normalizedURLs := make([]string, 0, len(inputs)) + lookupInputs := make([]monitoringAliasLookupInput, 0, len(inputs)) seenURLs := make(map[string]struct{}, len(inputs)) for _, item := range inputs { key := strings.TrimSpace(monitoringStringValue(item.NormalizedURL)) @@ -2414,11 +2437,23 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p continue } seenURLs[key] = struct{}{} - normalizedURLs = append(normalizedURLs, key) + host, registrableDomain, _, _ := deriveCitationURLParts(item.URL, key) + siteKey := strings.TrimSpace(monitoringStringValue(item.SiteKey)) + if siteKey == "" { + siteKey = registrableDomain + } + lookupInputs = append(lookupInputs, monitoringAliasLookupInput{ + NormalizedURL: key, + RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)), + SiteKey: strings.ToLower(strings.TrimSpace(siteKey)), + Host: strings.ToLower(strings.TrimSpace(host)), + LastPathSegment: monitoringAliasLastPathSegment(key), + TitleKey: normalizeMonitoringAliasTitle(monitoringStringValue(item.Title)), + }) } var err error - aliasMap, err = s.loadAliasResolutions(ctx, tx, tenantID, normalizedURLs) + aliasMap, err = s.loadAliasResolutions(ctx, tx, tenantID, lookupInputs) if err != nil { return nil, err } @@ -2443,40 +2478,205 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p return result, nil } -func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx pgx.Tx, tenantID int64, normalizedURLs []string) (map[string]monitoringAliasResolution, error) { +func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx pgx.Tx, tenantID int64, lookupInputs []monitoringAliasLookupInput) (map[string]monitoringAliasResolution, error) { result := make(map[string]monitoringAliasResolution) - if len(normalizedURLs) == 0 { + if len(lookupInputs) == 0 { return result, nil } + normalizedURLs := make([]string, 0, len(lookupInputs)) + domainKeys := make([]string, 0, len(lookupInputs)*3) + seenDomainKeys := map[string]struct{}{} + for _, input := range lookupInputs { + if input.NormalizedURL != "" { + normalizedURLs = append(normalizedURLs, input.NormalizedURL) + } + for _, key := range []string{input.RegistrableDomain, input.SiteKey, input.Host} { + key = strings.ToLower(strings.TrimSpace(key)) + if key == "" { + continue + } + if _, ok := seenDomainKeys[key]; ok { + continue + } + seenDomainKeys[key] = struct{}{} + domainKeys = append(domainKeys, key) + } + } + rows, err := tx.Query(ctx, ` - SELECT normalized_url, article_id, publish_record_id + SELECT + normalized_url, + article_id, + publish_record_id, + registrable_domain, + site_key, + host, + last_path_segment, + article_title_snapshot FROM monitoring_article_url_aliases WHERE tenant_id = $1 - AND normalized_url = ANY($2) - `, tenantID, normalizedURLs) + AND ( + normalized_url = ANY($2::text[]) + OR NULLIF(LOWER(TRIM(registrable_domain)), '') = ANY($3::text[]) + OR NULLIF(LOWER(TRIM(site_key)), '') = ANY($3::text[]) + OR NULLIF(LOWER(TRIM(host)), '') = ANY($3::text[]) + ) + `, tenantID, normalizedURLs, domainKeys) if err != nil { return nil, response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases") } defer rows.Close() + candidates := make([]monitoringAliasCandidate, 0) for rows.Next() { - var normalizedURL string + var candidate monitoringAliasCandidate var articleID sql.NullInt64 var publishRecordID sql.NullInt64 - if scanErr := rows.Scan(&normalizedURL, &articleID, &publishRecordID); scanErr != nil { + var registrableDomain sql.NullString + var siteKey sql.NullString + var host sql.NullString + var lastPathSegment sql.NullString + var articleTitle sql.NullString + if scanErr := rows.Scan( + &candidate.NormalizedURL, + &articleID, + &publishRecordID, + ®istrableDomain, + &siteKey, + &host, + &lastPathSegment, + &articleTitle, + ); scanErr != nil { return nil, response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution") } - result[normalizedURL] = monitoringAliasResolution{ - ArticleID: nullableInt64Value(articleID), - PublishRecordID: nullableInt64Value(publishRecordID), - NormalizedURLKey: normalizedURL, + candidate.ArticleID = nullableInt64Value(articleID) + candidate.PublishRecordID = nullableInt64Value(publishRecordID) + candidate.RegistrableDomain = strings.ToLower(strings.TrimSpace(nullableStringText(registrableDomain))) + candidate.SiteKey = strings.ToLower(strings.TrimSpace(nullableStringText(siteKey))) + candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host))) + candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment)) + candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle)) + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "alias_scan_failed", "failed to iterate article aliases") + } + + for _, input := range lookupInputs { + bestScore := -1 + bestAmbiguous := false + var best monitoringAliasCandidate + domainMatchedArticleIDs := map[int64]struct{}{} + for _, candidate := range candidates { + if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil { + domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{} + } + score := scoreMonitoringAliasCandidate(input, candidate) + if score < 0 { + continue + } + if score > bestScore { + bestScore = score + bestAmbiguous = false + best = candidate + continue + } + if score == bestScore && !sameInt64Pointer(candidate.ArticleID, best.ArticleID) { + bestAmbiguous = true + } + } + if bestScore == 40 && len(domainMatchedArticleIDs) == 1 { + bestScore = 60 + } + + if bestScore < 60 || bestAmbiguous { + continue + } + confidence := "medium" + status := "domain_matched" + if bestScore >= 100 { + confidence = "high" + status = "resolved" + } else if bestScore < 80 { + confidence = "low" + } + result[input.NormalizedURL] = monitoringAliasResolution{ + ArticleID: cloneInt64(best.ArticleID), + PublishRecordID: cloneInt64(best.PublishRecordID), + NormalizedURLKey: best.NormalizedURL, + ResolutionStatus: status, + ResolutionConfidence: confidence, } } return result, nil } +func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) int { + if input.NormalizedURL != "" && candidate.NormalizedURL == input.NormalizedURL { + return 100 + } + + if !monitoringAliasDomainMatches(input, candidate) { + return -1 + } + + score := 40 + if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) { + score += 30 + } + if input.TitleKey != "" && candidate.TitleKey != "" { + switch { + case input.TitleKey == candidate.TitleKey: + score += 30 + case strings.Contains(input.TitleKey, candidate.TitleKey) || strings.Contains(candidate.TitleKey, input.TitleKey): + score += 15 + } + } + return score +} + +func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool { + inputKeys := []string{input.RegistrableDomain, input.SiteKey, input.Host} + candidateKeys := []string{candidate.RegistrableDomain, candidate.SiteKey, candidate.Host} + for _, inputKey := range inputKeys { + inputKey = strings.ToLower(strings.TrimSpace(inputKey)) + if inputKey == "" { + continue + } + for _, candidateKey := range candidateKeys { + if inputKey == strings.ToLower(strings.TrimSpace(candidateKey)) { + return true + } + } + } + return false +} + +func normalizeMonitoringAliasTitle(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + return unicode.ToLower(r) + } + return -1 + }, value) +} + +func nullableStringText(value sql.NullString) string { + if !value.Valid { + return "" + } + return strings.TrimSpace(value.String) +} + +func sameInt64Pointer(left, right *int64) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + func (s *MonitoringCallbackService) insertCitationFact(ctx context.Context, tx pgx.Tx, task *monitoringCollectTask, runID int64, fact monitoringCitationFact) error { if _, err := tx.Exec(ctx, ` INSERT INTO monitoring_citation_facts ( @@ -2809,10 +3009,13 @@ func buildMonitoringCitationFact(input MonitoringSourceItem, aliasMap map[string var articleID *int64 var publishRecordID *int64 + var aliasResolution *monitoringAliasResolution if resolveContentCitations { articleID = cloneInt64(input.ArticleID) publishRecordID = cloneInt64(input.PublishRecordID) if alias, ok := aliasMap[normalizedURL]; ok { + aliasCopy := alias + aliasResolution = &aliasCopy if articleID == nil { articleID = cloneInt64(alias.ArticleID) } @@ -2824,11 +3027,19 @@ func buildMonitoringCitationFact(input MonitoringSourceItem, aliasMap map[string resolutionStatus := strings.TrimSpace(monitoringStringValue(input.ResolutionStatus)) if resolutionStatus == "" { - resolutionStatus = "resolved" + if aliasResolution != nil && strings.TrimSpace(aliasResolution.ResolutionStatus) != "" { + resolutionStatus = strings.TrimSpace(aliasResolution.ResolutionStatus) + } else { + resolutionStatus = "resolved" + } } resolutionConfidence := strings.TrimSpace(monitoringStringValue(input.ResolutionConfidence)) if resolutionConfidence == "" { - resolutionConfidence = "high" + if aliasResolution != nil && strings.TrimSpace(aliasResolution.ResolutionConfidence) != "" { + resolutionConfidence = strings.TrimSpace(aliasResolution.ResolutionConfidence) + } else { + resolutionConfidence = "high" + } } return monitoringCitationFact{ @@ -3113,7 +3324,7 @@ func normalizeCitationURL(raw string) string { parsed.Host = strings.ToLower(parsed.Host) parsed.Fragment = "" parsed.RawFragment = "" - parsed.RawQuery = "" + parsed.RawQuery = normalizeCitationQuery(parsed) if parsed.Path != "/" { parsed.Path = strings.TrimRight(parsed.Path, "/") @@ -3122,6 +3333,30 @@ func normalizeCitationURL(raw string) string { return parsed.String() } +func normalizeCitationQuery(parsed *url.URL) string { + if parsed == nil { + return "" + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + allowedParams := []string{} + switch { + case host == "baijiahao.baidu.com": + allowedParams = []string{"id"} + } + if len(allowedParams) == 0 { + return "" + } + + query := parsed.Query() + normalized := url.Values{} + for _, key := range allowedParams { + if value := strings.TrimSpace(query.Get(key)); value != "" { + normalized.Set(key, value) + } + } + return normalized.Encode() +} + func deriveCitationURLParts(rawURL, normalizedURL string) (string, string, *string, *string) { candidate := strings.TrimSpace(normalizedURL) if candidate == "" { diff --git a/server/internal/tenant/app/monitoring_callback_service_test.go b/server/internal/tenant/app/monitoring_callback_service_test.go index 48db5e5..e0e3285 100644 --- a/server/internal/tenant/app/monitoring_callback_service_test.go +++ b/server/internal/tenant/app/monitoring_callback_service_test.go @@ -425,6 +425,26 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) { } } +func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) { + lastPath := "article-42" + input := monitoringAliasLookupInput{ + NormalizedURL: "https://m.example.com/article-42", + RegistrableDomain: "example.com", + LastPathSegment: &lastPath, + TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"), + } + candidate := monitoringAliasCandidate{ + NormalizedURL: "https://www.example.com/posts/article-42", + RegistrableDomain: "example.com", + LastPathSegment: "article-42", + TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"), + } + + if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 { + t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score) + } +} + func TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs(t *testing.T) { searchTitle := "搜索网页结果" diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go index bf97f02..8b0709e 100644 --- a/server/internal/tenant/app/monitoring_service.go +++ b/server/internal/tenant/app/monitoring_service.go @@ -74,12 +74,25 @@ func (s *MonitoringService) WithRedis(redis *goredis.Client) *MonitoringService 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"` @@ -126,12 +139,15 @@ type MonitoringHotQuestion struct { } 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"` + 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 { @@ -140,6 +156,7 @@ type MonitoringCitedArticle struct { PublishPlatform string `json:"publish_platform"` CitationCount int64 `json:"citation_count"` CitationRate *float64 `json:"citation_rate"` + SourceShare *float64 `json:"source_share"` } type MonitoringQuestionDetailResponse struct { @@ -347,7 +364,6 @@ func (s *MonitoringService) DashboardComposite( if err != nil { return nil, err } - questionIDs := configuredQuestionIDs(configuredQuestions) platforms := defaultMonitoringPlatformMetadata() selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID) @@ -380,23 +396,59 @@ func (s *MonitoringService) DashboardComposite( return nil, err } - citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, accessStates, selectedPlatformID) - if err != nil { - return nil, err - } - - citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, selectedPlatformID) - if err != nil { - return nil, err - } - return &MonitoringDashboardCompositeResponse{ - Overview: overview, - Runtime: runtime, + 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: citationRanking, - CitedArticles: citedArticles, + CitationRanking: []MonitoringCitationRanking{}, + CitedArticles: []MonitoringCitedArticle{}, + }, nil +} + +func (s *MonitoringService) CitationSummary( + ctx context.Context, + days int, +) (*MonitoringCitationSummaryResponse, error) { + actor := auth.MustActor(ctx) + workspaceID := auth.CurrentWorkspaceID(ctx) + + quota, err := s.loadQuota(ctx, actor, workspaceID) + if err != nil { + return nil, err + } + + citationWindowDays := normalizeCitationWindowDays(days) + startDate, endDate := trackingDateWindow(citationWindowDays) + + accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate) + if err != nil { + return nil, err + } + + citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil) + if err != nil { + return nil, err + } + + citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil) + 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 } @@ -509,12 +561,12 @@ func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questio }) } - citationAnalysis, err := s.loadQuestionCitationAnalysis(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, selectedPlatformID) + citationAnalysis, err := s.loadQuestionCitationAnalysis(ctx, actor.TenantID, runIDs) if err != nil { return nil, err } - contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, selectedPlatformID) + contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, runIDs) if err != nil { return nil, err } @@ -2430,13 +2482,26 @@ func (s *MonitoringService) loadCitationRanking( platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) rows, err := s.monitoringPool.Query(ctx, ` - WITH run_counts AS ( + WITH published_domains AS ( + SELECT DISTINCT domain_key + FROM ( + SELECT NULLIF(LOWER(TRIM(host)), '') AS domain_key + FROM monitoring_article_url_aliases + WHERE tenant_id = $1 + UNION + SELECT NULLIF(LOWER(TRIM(site_key)), '') AS domain_key + FROM monitoring_article_url_aliases + WHERE tenant_id = $1 + ) domains + WHERE domain_key IS NOT NULL + ), + 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 ($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 @@ -2444,36 +2509,65 @@ func (s *MonitoringService) loadCitationRanking( AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) GROUP BY r.ai_platform_id ), - citation_counts AS ( + citation_sources AS ( SELECT r.ai_platform_id, - COUNT(DISTINCT cf.run_id) FILTER (WHERE cf.article_id IS NOT NULL AND r.ai_platform_id <> 'qwen') AS cited_answer_count, - COUNT(DISTINCT cf.article_id) FILTER (WHERE cf.article_id IS NOT NULL AND r.ai_platform_id <> 'qwen') AS cited_article_count + cf.id, + cf.run_id, + cf.article_id, + ( + cf.article_id IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM published_domains pd + WHERE pd.domain_key = ANY(ARRAY[ + LOWER(NULLIF(cf.registrable_domain, '')), + LOWER(NULLIF(cf.site_key, '')), + LOWER(NULLIF(cf.host, '')) + ]) + ) + ) AS matched_saas_source 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 ($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 + ), + citation_counts AS ( + SELECT + ai_platform_id, + COUNT(*) AS citation_source_count, + COUNT(*) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS saas_source_count, + COUNT(DISTINCT run_id) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS cited_answer_count, + COUNT(DISTINCT article_id) FILTER (WHERE article_id IS NOT NULL AND ai_platform_id <> 'qwen') AS cited_article_count + FROM citation_sources + GROUP BY ai_platform_id ) SELECT rc.ai_platform_id, rc.sample_count, COALESCE(cc.cited_answer_count, 0) AS cited_answer_count, COALESCE(cc.cited_article_count, 0) AS cited_article_count, + COALESCE(cc.citation_source_count, 0) AS citation_source_count, + COALESCE(cc.saas_source_count, 0) AS saas_source_count, + CASE + WHEN COALESCE(cc.citation_source_count, 0) > 0 + THEN COALESCE(cc.saas_source_count, 0)::double precision / cc.citation_source_count::double precision + ELSE NULL + END AS saas_source_rate, 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 + WHERE COALESCE(cc.saas_source_count, 0) > 0 + ORDER BY cited_answer_count DESC, saas_source_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), nullableStringArray(platformQueryIDs)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") @@ -2481,9 +2575,11 @@ func (s *MonitoringService) loadCitationRanking( defer rows.Close() type citationRankingAggregate struct { - CitedAnswerCount int64 - CitedArticleCount int64 - SampleCount int64 + CitedAnswerCount int64 + CitedArticleCount int64 + CitationSourceCount int64 + SaaSSourceCount int64 + SampleCount int64 } aggregates := make(map[string]citationRankingAggregate) @@ -2492,7 +2588,9 @@ func (s *MonitoringService) loadCitationRanking( var sampleCount int64 var citedAnswerCount int64 var citedArticleCount int64 - if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, new(sql.NullFloat64)); scanErr != nil { + var citationSourceCount int64 + var saasSourceCount int64 + if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") } platformID = normalizeMonitoringPlatformID(platformID) @@ -2502,6 +2600,8 @@ func (s *MonitoringService) loadCitationRanking( item := aggregates[platformID] item.CitedAnswerCount += citedAnswerCount item.CitedArticleCount += citedArticleCount + item.CitationSourceCount += citationSourceCount + item.SaaSSourceCount += saasSourceCount item.SampleCount += sampleCount aggregates[platformID] = item } @@ -2509,18 +2609,24 @@ func (s *MonitoringService) loadCitationRanking( items := make([]MonitoringCitationRanking, 0, len(aggregates)) for platformID, aggregate := range aggregates { items = append(items, MonitoringCitationRanking{ - AIPlatformID: platformID, - PlatformName: platformDisplayName(platformID), - SampleStatus: derivePlatformSampleStatus("", accessStates[platformID]), - CitedAnswerCount: aggregate.CitedAnswerCount, - CitedArticleCount: aggregate.CitedArticleCount, - CitationRate: divideAsPointer(aggregate.CitedAnswerCount, aggregate.SampleCount), + 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 } @@ -2547,7 +2653,7 @@ func (s *MonitoringService) loadCitedArticles( SELECT COUNT(*) FROM question_monitor_runs r WHERE r.tenant_id = $1 - AND r.brand_id = $2 + 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 @@ -2558,26 +2664,51 @@ func (s *MonitoringService) loadCitedArticles( } rows, err := s.monitoringPool.Query(ctx, ` + WITH article_counts AS ( + 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 LATERAL ( + SELECT + alias.article_title_snapshot, + alias.publish_platform_name_snapshot + FROM monitoring_article_url_aliases alias + WHERE alias.tenant_id = cf.tenant_id + AND alias.article_id = cf.article_id + ORDER BY + (alias.publish_record_id = cf.publish_record_id) DESC NULLS LAST, + alias.updated_at DESC, + alias.id DESC + LIMIT 1 + ) alias ON TRUE + WHERE cf.tenant_id = $1 + AND ($2::bigint = 0 OR cf.brand_id = $2) + AND cf.article_id IS NOT NULL + 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)) + GROUP BY cf.article_id + ), + totals AS ( + SELECT COALESCE(SUM(citation_count), 0) AS total_article_citation_count + FROM article_counts + ) 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.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)) - GROUP BY cf.article_id + cf.article_title, + cf.publish_platform, + cf.citation_count, + t.total_article_citation_count + FROM article_counts cf + CROSS JOIN totals t 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), nullableStringArray(platformQueryIDs)) @@ -2592,7 +2723,8 @@ func (s *MonitoringService) loadCitedArticles( var title string var publishPlatform string var citationCount int64 - if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount); scanErr != nil { + var totalArticleCitationCount int64 + if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") } items = append(items, MonitoringCitedArticle{ @@ -2601,6 +2733,7 @@ func (s *MonitoringService) loadCitedArticles( PublishPlatform: publishPlatform, CitationCount: citationCount, CitationRate: divideAsPointer(citationCount, totalSampleCount), + SourceShare: divideAsPointer(citationCount, totalArticleCitationCount), }) } @@ -2902,8 +3035,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i return result, nil } -func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionCitationStats, error) { - platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) +func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID int64, runIDs []int64) ([]MonitoringQuestionCitationStats, error) { + if len(runIDs) == 0 { + return []MonitoringQuestionCitationStats{}, nil + } rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_facts AS ( @@ -2918,13 +3053,8 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te 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 = ANY($8)) + AND r.id = ANY($2) ), domain_keys AS ( SELECT DISTINCT host, registrable_domain, site_key @@ -3003,7 +3133,7 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te 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"), nullableStringArray(platformQueryIDs)) + `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis") } @@ -3071,8 +3201,10 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te return items, nil } -func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionContentCitation, error) { - platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) +func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID int64, runIDs []int64) ([]MonitoringQuestionContentCitation, error) { + if len(runIDs) == 0 { + return []MonitoringQuestionContentCitation{}, nil + } rows, err := s.monitoringPool.Query(ctx, ` WITH platform_totals AS ( @@ -3083,13 +3215,8 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te 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 = ANY($8)) + AND r.id = ANY($2) GROUP BY r.ai_platform_id ) SELECT @@ -3107,18 +3234,13 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te 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 r.ai_platform_id <> 'qwen' - AND ($8::text[] IS NULL OR r.ai_platform_id = ANY($8)) + AND r.id = ANY($2) 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"), nullableStringArray(platformQueryIDs)) + `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations") } @@ -3326,6 +3448,13 @@ func normalizeTrackingDays(days int) int { 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 diff --git a/server/internal/tenant/app/publish_record_support.go b/server/internal/tenant/app/publish_record_support.go index f653a01..98ebe1e 100644 --- a/server/internal/tenant/app/publish_record_support.go +++ b/server/internal/tenant/app/publish_record_support.go @@ -28,8 +28,21 @@ type platformAccountSeed struct { } type desktopPublishSyncOutcome struct { - TenantID int64 - ArticleID int64 + TenantID int64 + ArticleID int64 + ArticleAlias *monitoringArticleAliasInput +} + +type monitoringArticleAliasInput struct { + TenantID int64 + ArticleID int64 + PublishRecordID int64 + PlatformID string + PlatformName string + ArticleTitle string + ExternalArticleID *string + ExternalArticleURL *string + ExternalManageURL *string } func loadPublishableArticleMeta(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*publishableArticleMeta, error) { @@ -136,6 +149,10 @@ func syncDesktopPublishTaskState( publishedAt = &now } + externalArticleID := extractStringPointer(resultPayload, "external_article_id", "externalArticleId") + externalArticleURL := extractStringPointer(resultPayload, "external_article_url", "externalArticleUrl") + externalManageURL := extractStringPointer(resultPayload, "external_manage_url", "externalManageUrl", "review_url", "reviewUrl") + if _, err := tx.Exec(ctx, ` UPDATE publish_records SET status = $1, @@ -149,9 +166,9 @@ func syncDesktopPublishTaskState( updated_at = NOW() WHERE id = $9 AND tenant_id = $10 `, publishStatus, - extractStringPointer(resultPayload, "external_article_id", "externalArticleId"), - extractStringPointer(resultPayload, "external_article_url", "externalArticleUrl"), - extractStringPointer(resultPayload, "external_manage_url", "externalManageUrl", "review_url", "reviewUrl"), + externalArticleID, + externalArticleURL, + externalManageURL, publishedAt, nullableJSON(task.Payload), nullableJSON(task.Result), @@ -183,10 +200,42 @@ func syncDesktopPublishTaskState( return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status") } - return &desktopPublishSyncOutcome{ + outcome := &desktopPublishSyncOutcome{ TenantID: task.TenantID, ArticleID: articleID, - }, nil + } + if publishStatus == "success" { + var platformID string + var platformName string + var articleTitle string + if err := tx.QueryRow(ctx, ` + SELECT + pr.platform_id, + COALESCE(mp.name, pr.platform_id) AS platform_name, + COALESCE(NULLIF(av.title, ''), '未命名文章') AS article_title + FROM publish_records pr + LEFT JOIN media_platforms mp + ON mp.platform_id = pr.platform_id + LEFT JOIN articles a + ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id + LEFT JOIN article_versions av + ON av.id = a.current_version_id + WHERE pr.id = $1 AND pr.tenant_id = $2 + `, publishRecordID, task.TenantID).Scan(&platformID, &platformName, &articleTitle); err == nil { + outcome.ArticleAlias = &monitoringArticleAliasInput{ + TenantID: task.TenantID, + ArticleID: articleID, + PublishRecordID: publishRecordID, + PlatformID: platformID, + PlatformName: platformName, + ArticleTitle: articleTitle, + ExternalArticleID: externalArticleID, + ExternalArticleURL: externalArticleURL, + ExternalManageURL: externalManageURL, + } + } + } + return outcome, nil } func loadPlatformAccountSeedByDesktopID( diff --git a/server/internal/tenant/transport/monitoring_handler.go b/server/internal/tenant/transport/monitoring_handler.go index 1d43381..5b85627 100644 --- a/server/internal/tenant/transport/monitoring_handler.go +++ b/server/internal/tenant/transport/monitoring_handler.go @@ -59,6 +59,21 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) { response.Success(c, data) } +func (h *MonitoringHandler) CitationSummary(c *gin.Context) { + days, err := parseOptionalInt(c.Query("days")) + if err != nil { + response.Error(c, response.ErrBadRequest(40031, "invalid_days", "days must be a number")) + return + } + + data, svcErr := h.svc.CitationSummary(c.Request.Context(), days) + if svcErr != nil { + response.Error(c, svcErr) + return + } + response.Success(c, data) +} + func (h *MonitoringHandler) QuestionDetail(c *gin.Context) { brandID, err := strconv.ParseInt(c.Param("brand_id"), 10, 64) if err != nil { diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index e264cd5..0c99b85 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -183,6 +183,7 @@ func RegisterRoutes(app *bootstrap.App) { monitoring := tenantProtected.Group("/monitoring") monitoringHandler := NewMonitoringHandler(app) monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite) + monitoring.GET("/citation-summary", monitoringHandler.CitationSummary) monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail) monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow) diff --git a/server/migrations_monitoring/20260430130000_add_article_alias_domain_keys.down.sql b/server/migrations_monitoring/20260430130000_add_article_alias_domain_keys.down.sql new file mode 100644 index 0000000..ff36056 --- /dev/null +++ b/server/migrations_monitoring/20260430130000_add_article_alias_domain_keys.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_article_url_alias_site_key; +DROP INDEX IF EXISTS idx_article_url_alias_domain; + +ALTER TABLE monitoring_article_url_aliases + DROP COLUMN IF EXISTS site_key, + DROP COLUMN IF EXISTS registrable_domain, + DROP COLUMN IF EXISTS host; diff --git a/server/migrations_monitoring/20260430130000_add_article_alias_domain_keys.up.sql b/server/migrations_monitoring/20260430130000_add_article_alias_domain_keys.up.sql new file mode 100644 index 0000000..fd554b3 --- /dev/null +++ b/server/migrations_monitoring/20260430130000_add_article_alias_domain_keys.up.sql @@ -0,0 +1,27 @@ +ALTER TABLE monitoring_article_url_aliases + ADD COLUMN IF NOT EXISTS host VARCHAR(255), + ADD COLUMN IF NOT EXISTS registrable_domain VARCHAR(255), + ADD COLUMN IF NOT EXISTS site_key VARCHAR(255); + +WITH parsed_aliases AS ( + SELECT + id, + LOWER(SPLIT_PART(SUBSTRING(original_url FROM '^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)'), ':', 1)) AS parsed_host + FROM monitoring_article_url_aliases +) +UPDATE monitoring_article_url_aliases alias +SET host = COALESCE(NULLIF(alias.host, ''), parsed_aliases.parsed_host), + registrable_domain = COALESCE(NULLIF(alias.registrable_domain, ''), parsed_aliases.parsed_host), + site_key = COALESCE(NULLIF(alias.site_key, ''), parsed_aliases.parsed_host) +FROM parsed_aliases +WHERE alias.id = parsed_aliases.id + AND parsed_aliases.parsed_host IS NOT NULL + AND parsed_aliases.parsed_host <> ''; + +CREATE INDEX IF NOT EXISTS idx_article_url_alias_domain + ON monitoring_article_url_aliases(tenant_id, registrable_domain) + WHERE registrable_domain IS NOT NULL AND registrable_domain <> ''; + +CREATE INDEX IF NOT EXISTS idx_article_url_alias_site_key + ON monitoring_article_url_aliases(tenant_id, site_key) + WHERE site_key IS NOT NULL AND site_key <> '';