From b345ee26e462cebc0df5ada79aee2a823b3bfcd3 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 1 May 2026 11:46:17 +0800 Subject: [PATCH] feat(monitoring): match citations by canonical link and scope summary by brand/keyword/platform/date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the domain+title fuzzy alias scoring with a canonical candidate-key index built from published-link aliases — only exact matches now resolve to a SaaS source, eliminating false positives across articles sharing a host (e.g. m.163.com vs www.163.com). Surface the summary scoping that already existed on loadCitationRanking / loadCitedArticles by accepting brand_id, keyword_id, ai_platform_id and business_date on CitationSummary, plumbing them through the handler and admin tracking view. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/i18n/messages/en-US.ts | 2 +- apps/admin-web/src/i18n/messages/zh-CN.ts | 2 +- apps/admin-web/src/lib/api.ts | 4 + apps/admin-web/src/views/TrackingView.vue | 9 + .../tenant/app/monitoring_callback_service.go | 51 +- .../app/monitoring_callback_service_test.go | 34 +- .../tenant/app/monitoring_citation_match.go | 251 +++++++++ .../internal/tenant/app/monitoring_service.go | 520 +++++++++--------- .../tenant/app/publish_record_support_test.go | 10 + .../tenant/transport/monitoring_handler.go | 16 +- 10 files changed, 592 insertions(+), 307 deletions(-) create mode 100644 server/internal/tenant/app/monitoring_citation_match.go diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 89a5bb1..a89e85a 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -432,7 +432,7 @@ const enUS = { backToQuestions: "Back to questions", citationRanking: "Citation Ranking", citationRankingTitle: "Citation Ranking", - citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.", + citationRankingHint: "Attribute SaaS-published content by published-link identity across model citation sources.", citationWindow7: "Last 7 days", citationWindow30: "Last 30 days", citationWindowLabel: "Last {days} days", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 6776359..a4ac578 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -433,7 +433,7 @@ const zhCN = { backToQuestions: "返回问题列表", citationRanking: "Citation Ranking", citationRankingTitle: "引用排行", - citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。", + citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。", citationWindow7: "近 7 天", citationWindow30: "近 30 天", citationWindowLabel: "近 {days} 天", diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 1d49927..fc1361e 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -1078,6 +1078,10 @@ export const monitoringApi = { }, citationSummary(params: { days?: number; + brand_id?: number; + keyword_id?: number | null; + business_date?: string; + ai_platform_id?: string; }) { return apiClient.get("/api/tenant/monitoring/citation-summary", { params, diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue index a7ed47a..ac1f069 100644 --- a/apps/admin-web/src/views/TrackingView.vue +++ b/apps/admin-web/src/views/TrackingView.vue @@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({ queryKey: computed(() => [ "tracking", "citation-summary", + selectedBrandId.value, + selectedKeywordId.value, + selectedPlatformId.value, + selectedBusinessDate.value, selectedCitationWindowDays.value, ]), + enabled: computed(() => Boolean(selectedBrandId.value)), queryFn: () => monitoringApi.citationSummary({ + brand_id: selectedBrandId.value ?? undefined, + keyword_id: selectedKeywordId.value, + business_date: selectedBusinessDate.value, + ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined, days: selectedCitationWindowDays.value, }), }); diff --git a/server/internal/tenant/app/monitoring_callback_service.go b/server/internal/tenant/app/monitoring_callback_service.go index b7a566b..fd26640 100644 --- a/server/internal/tenant/app/monitoring_callback_service.go +++ b/server/internal/tenant/app/monitoring_callback_service.go @@ -284,6 +284,7 @@ type monitoringAliasResolution struct { type monitoringAliasLookupInput struct { NormalizedURL string + MatchKeys []string RegistrableDomain string SiteKey string Host string @@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct { type monitoringAliasCandidate struct { NormalizedURL string + MatchKeys []string ArticleID *int64 PublishRecordID *int64 RegistrableDomain string @@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p } lookupInputs = append(lookupInputs, monitoringAliasLookupInput{ NormalizedURL: key, + MatchKeys: monitoringCitationCandidateKeys(item.URL, key), RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)), SiteKey: strings.ToLower(strings.TrimSpace(siteKey)), Host: strings.ToLower(strings.TrimSpace(host)), @@ -2557,6 +2560,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host))) candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment)) candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle)) + candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL) candidates = append(candidates, candidate) } if err := rows.Err(); err != nil { @@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx 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 @@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx bestAmbiguous = true } } - if bestScore == 40 && len(domainMatchedArticleIDs) == 1 { - bestScore = 60 - } - if bestScore < 60 || bestAmbiguous { + if bestScore < 100 || 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, + ResolutionStatus: "resolved", + ResolutionConfidence: "high", } } @@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m 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 + for _, inputKey := range input.MatchKeys { + inputKey = strings.TrimSpace(inputKey) + if inputKey == "" { + continue + } + for _, candidateKey := range candidate.MatchKeys { + if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" { + return 100 + } } } - return score + + return -1 } func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool { diff --git a/server/internal/tenant/app/monitoring_callback_service_test.go b/server/internal/tenant/app/monitoring_callback_service_test.go index e0e3285..27b75fb 100644 --- a/server/internal/tenant/app/monitoring_callback_service_test.go +++ b/server/internal/tenant/app/monitoring_callback_service_test.go @@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) { } } -func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) { - lastPath := "article-42" +func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) { input := monitoringAliasLookupInput{ - NormalizedURL: "https://m.example.com/article-42", - RegistrableDomain: "example.com", - LastPathSegment: &lastPath, - TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"), + NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", + MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""), } candidate := monitoringAliasCandidate{ - NormalizedURL: "https://www.example.com/posts/article-42", - RegistrableDomain: "example.com", - LastPathSegment: "article-42", - TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"), + NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", + MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""), } - if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 { - t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score) + if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 { + t.Fatalf("scoreMonitoringAliasCandidate() = %d, want canonical URL match", score) + } +} + +func TestScoreMonitoringAliasCandidateRejectsSameDomainDifferentArticle(t *testing.T) { + input := monitoringAliasLookupInput{ + NormalizedURL: "https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html", + MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html", ""), + } + candidate := monitoringAliasCandidate{ + NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", + MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""), + } + + if score := scoreMonitoringAliasCandidate(input, candidate); score != -1 { + t.Fatalf("scoreMonitoringAliasCandidate() = %d, want no same-domain match", score) } } diff --git a/server/internal/tenant/app/monitoring_citation_match.go b/server/internal/tenant/app/monitoring_citation_match.go new file mode 100644 index 0000000..2570125 --- /dev/null +++ b/server/internal/tenant/app/monitoring_citation_match.go @@ -0,0 +1,251 @@ +package app + +import ( + "context" + "database/sql" + "net" + "net/url" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type monitoringPublishedAlias struct { + ArticleID int64 + PublishRecordID *int64 + ArticleTitle string + PublishPlatform string + Ambiguous bool +} + +type monitoringPublishedAliasIndex map[string]monitoringPublishedAlias + +type monitoringAliasQuerier interface { + Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) +} + +func loadMonitoringPublishedAliasIndex(ctx context.Context, q monitoringAliasQuerier, tenantID int64) (monitoringPublishedAliasIndex, error) { + rows, err := q.Query(ctx, ` + SELECT + article_id, + publish_record_id, + COALESCE(article_title_snapshot, '未命名文章') AS article_title, + COALESCE(publish_platform_name_snapshot, '已发布内容') AS publish_platform, + original_url, + normalized_url + FROM monitoring_article_url_aliases + WHERE tenant_id = $1 + AND confidence = 'high' + ORDER BY updated_at DESC, id DESC + `, tenantID) + if err != nil { + return nil, responseInternalAliasLookupError() + } + defer rows.Close() + + index := make(monitoringPublishedAliasIndex) + for rows.Next() { + var item monitoringPublishedAlias + var originalURL string + var normalizedURL string + var publishRecordID sql.NullInt64 + if scanErr := rows.Scan(&item.ArticleID, &publishRecordID, &item.ArticleTitle, &item.PublishPlatform, &originalURL, &normalizedURL); scanErr != nil { + return nil, responseInternalAliasScanError() + } + item.PublishRecordID = nullableInt64Value(publishRecordID) + for _, key := range monitoringCitationCandidateKeys(originalURL, normalizedURL) { + existing, exists := index[key] + if exists && existing.ArticleID != item.ArticleID { + existing.Ambiguous = true + index[key] = existing + continue + } + if !exists { + index[key] = item + } + } + } + if err := rows.Err(); err != nil { + return nil, responseInternalAliasScanError() + } + + return index, nil +} + +func (index monitoringPublishedAliasIndex) Match(rawURL, normalizedURL string) (monitoringPublishedAlias, bool) { + for _, key := range monitoringCitationCandidateKeys(rawURL, normalizedURL) { + item, ok := index[key] + if ok && !item.Ambiguous { + return item, true + } + } + return monitoringPublishedAlias{}, false +} + +func monitoringCitationCandidateKeys(rawURL, normalizedURL string) []string { + keys := []string{ + monitoringCitationURLMatchKey(rawURL), + monitoringCitationURLMatchKey(normalizedURL), + } + result := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, key) + } + return result +} + +func monitoringCitationURLMatchKey(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Hostname() == "" { + return "" + } + + host := monitoringCanonicalCitationHost(parsed.Hostname()) + if host == "" { + return "" + } + + if strings.HasSuffix(host, "163.com") { + if id := monitoringNeteaseArticleID(trimmed); id != "" { + return "163.com/dy/article/" + strings.ToLower(id) + } + } + + if host == "baijiahao.baidu.com" { + if id := extractBaijiahaoArticleIDFromURL(&trimmed); id != "" { + return host + "/s?id=" + strings.ToLower(id) + } + } + + path := strings.TrimRight(parsed.EscapedPath(), "/") + if path == "" { + path = "/" + } + + if host == "mp.weixin.qq.com" { + if strings.HasPrefix(path, "/s/") && len(path) > len("/s/") { + return host + path + } + if query := monitoringCanonicalQuery(parsed, []string{"__biz", "mid", "idx", "sn"}); query != "" { + return host + path + "?" + query + } + return "" + } + + if path == "/" { + if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" { + return host + path + "?" + query + } + return "" + } + + if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" { + return host + path + "?" + query + } + return host + path +} + +func monitoringCanonicalCitationHost(value string) string { + host := strings.ToLower(strings.TrimSpace(value)) + if host == "" { + return "" + } + if withoutPort, _, err := net.SplitHostPort(host); err == nil { + host = withoutPort + } + host = strings.Trim(host, ".") + parts := strings.Split(host, ".") + if len(parts) <= 2 { + return host + } + switch parts[0] { + case "www", "m", "wap", "mobile", "3g", "amp": + return strings.Join(parts[1:], ".") + default: + return host + } +} + +func monitoringCanonicalQuery(parsed *url.URL, allowedKeys []string) string { + if parsed == nil || len(allowedKeys) == 0 { + return "" + } + values := parsed.Query() + pairs := make([]string, 0, len(allowedKeys)) + for _, key := range allowedKeys { + value := strings.TrimSpace(values.Get(key)) + if value == "" { + continue + } + pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value)) + } + sort.Strings(pairs) + return strings.Join(pairs, "&") +} + +func monitoringGenericContentQueryKeys() []string { + return []string{"id", "article_id", "articleId", "docid", "docId", "postId"} +} + +func monitoringNeteaseArticleID(rawURL string) string { + if id := extractWangyihaoArticleIDFromURL(&rawURL); id != "" { + return id + } + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "" + } + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for index := len(segments) - 1; index >= 0; index-- { + id := strings.TrimSuffix(strings.TrimSpace(segments[index]), ".html") + if monitoringLooksLikeNeteaseArticleID(id) { + return id + } + } + return "" +} + +func monitoringLooksLikeNeteaseArticleID(value string) bool { + if len(value) < 8 || len(value) > 32 { + return false + } + hasDigit := false + hasLetter := false + for _, r := range value { + switch { + case r >= '0' && r <= '9': + hasDigit = true + case r >= 'A' && r <= 'Z': + hasLetter = true + case r >= 'a' && r <= 'z': + hasLetter = true + default: + return false + } + } + return hasDigit && hasLetter +} + +func responseInternalAliasLookupError() error { + return response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases") +} + +func responseInternalAliasScanError() error { + return response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution") +} diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go index 8b0709e..73574e6 100644 --- a/server/internal/tenant/app/monitoring_service.go +++ b/server/internal/tenant/app/monitoring_service.go @@ -414,29 +414,55 @@ func (s *MonitoringService) DashboardComposite( func (s *MonitoringService) CitationSummary( ctx context.Context, days int, + brandID int64, + keywordID *int64, + businessDate string, + aiPlatformID *string, ) (*MonitoringCitationSummaryResponse, error) { actor := auth.MustActor(ctx) workspaceID := auth.CurrentWorkspaceID(ctx) + var questionIDs []int64 + if brandID != 0 { + brand, err := s.resolveBrand(ctx, actor.TenantID, brandID) + if err != nil { + return nil, err + } + if keywordID != nil { + if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil { + return nil, err + } + } + configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID) + if err != nil { + return nil, err + } + questionIDs = configuredQuestionIDs(configuredQuestions) + } + quota, err := s.loadQuota(ctx, actor, workspaceID) if err != nil { return nil, err } citationWindowDays := normalizeCitationWindowDays(days) - startDate, endDate := trackingDateWindow(citationWindowDays) + startDate, endDate, err := resolveDashboardDateWindow(citationWindowDays, businessDate) + if err != nil { + return nil, err + } accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate) if err != nil { return nil, err } - citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil) + selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID) + citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, accessStates, selectedPlatformID) if err != nil { return nil, err } - citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil) + citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID) if err != nil { return nil, err } @@ -2481,98 +2507,10 @@ func (s *MonitoringService) loadCitationRanking( } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) - rows, err := s.monitoringPool.Query(ctx, ` - 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 ($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_sources AS ( - SELECT - r.ai_platform_id, - 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 ($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)) - ), - 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.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)) + aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) if err != nil { - return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") + return nil, err } - defer rows.Close() type citationRankingAggregate struct { CitedAnswerCount int64 @@ -2583,14 +2521,29 @@ func (s *MonitoringService) loadCitationRanking( } aggregates := make(map[string]citationRankingAggregate) + rows, err := s.monitoringPool.Query(ctx, ` + SELECT + r.ai_platform_id, + COUNT(*) AS sample_count + FROM question_monitor_runs r + WHERE r.tenant_id = $1 + AND ($2::bigint = 0 OR r.brand_id = $2) + AND r.collector_type = $3 + AND r.status = 'succeeded' + AND r.business_date BETWEEN $4::date AND $5::date + AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) + AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) + GROUP BY r.ai_platform_id + `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) + if err != nil { + return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") + } + defer rows.Close() + for rows.Next() { var platformID string var sampleCount int64 - var citedAnswerCount int64 - var citedArticleCount int64 - var citationSourceCount int64 - var saasSourceCount int64 - if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil { + if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") } platformID = normalizeMonitoringPlatformID(platformID) @@ -2598,16 +2551,80 @@ func (s *MonitoringService) loadCitationRanking( continue } item := aggregates[platformID] - item.CitedAnswerCount += citedAnswerCount - item.CitedArticleCount += citedArticleCount - item.CitationSourceCount += citationSourceCount - item.SaaSSourceCount += saasSourceCount item.SampleCount += sampleCount aggregates[platformID] = item } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking") + } + + citedRuns := make(map[string]map[int64]struct{}) + citedArticles := make(map[string]map[int64]struct{}) + rows, err = s.monitoringPool.Query(ctx, ` + SELECT + r.ai_platform_id, + cf.run_id, + cf.cited_url, + cf.normalized_url + FROM question_monitor_runs r + JOIN monitoring_citation_facts cf + ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id + WHERE r.tenant_id = $1 + AND ($2::bigint = 0 OR r.brand_id = $2) + AND r.collector_type = $3 + AND r.status = 'succeeded' + AND r.business_date BETWEEN $4::date AND $5::date + AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) + AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) + `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) + if err != nil { + return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") + } + defer rows.Close() + + for rows.Next() { + var platformID string + var runID int64 + var citedURL string + var normalizedURL string + if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") + } + platformID = normalizeMonitoringPlatformID(platformID) + if platformID == "" { + continue + } + item := aggregates[platformID] + item.CitationSourceCount++ + if platformID != "qwen" { + if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok { + item.SaaSSourceCount++ + if _, exists := citedRuns[platformID]; !exists { + citedRuns[platformID] = make(map[int64]struct{}) + } + citedRuns[platformID][runID] = struct{}{} + if _, exists := citedArticles[platformID]; !exists { + citedArticles[platformID] = make(map[int64]struct{}) + } + citedArticles[platformID][alias.ArticleID] = struct{}{} + } + } + aggregates[platformID] = item + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking") + } + for platformID, item := range aggregates { + item.CitedAnswerCount = int64(len(citedRuns[platformID])) + item.CitedArticleCount = int64(len(citedArticles[platformID])) + aggregates[platformID] = item + } items := make([]MonitoringCitationRanking, 0, len(aggregates)) for platformID, aggregate := range aggregates { + if aggregate.SaaSSourceCount == 0 { + continue + } items = append(items, MonitoringCitationRanking{ AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), @@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles( } platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) + aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) + if err != nil { + return nil, err + } + var totalSampleCount int64 if err := s.monitoringPool.QueryRow(ctx, ` SELECT COUNT(*) @@ -2664,78 +2686,78 @@ 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, - 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 + cf.cited_url, + cf.normalized_url + FROM monitoring_citation_facts cf + JOIN question_monitor_runs r + ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id + WHERE cf.tenant_id = $1 + AND ($2::bigint = 0 OR cf.brand_id = $2) + AND r.ai_platform_id <> 'qwen' + AND r.collector_type = $3 + AND r.status = 'succeeded' + AND r.business_date BETWEEN $4::date AND $5::date + AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) + AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles") } defer rows.Close() - items := make([]MonitoringCitedArticle, 0) + type citedArticleAggregate struct { + ArticleID int64 + ArticleTitle string + PublishPlatform string + CitationCount int64 + } + aggregates := make(map[int64]citedArticleAggregate) + var totalArticleCitationCount int64 for rows.Next() { - var articleID int64 - var title string - var publishPlatform string - var citationCount int64 - var totalArticleCitationCount int64 - if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil { + var citedURL string + var normalizedURL string + if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") } + alias, ok := aliasIndex.Match(citedURL, normalizedURL) + if !ok { + continue + } + item := aggregates[alias.ArticleID] + if item.ArticleID == 0 { + item.ArticleID = alias.ArticleID + item.ArticleTitle = alias.ArticleTitle + item.PublishPlatform = alias.PublishPlatform + } + item.CitationCount++ + totalArticleCitationCount++ + aggregates[alias.ArticleID] = item + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles") + } + + items := make([]MonitoringCitedArticle, 0, len(aggregates)) + for _, item := range aggregates { items = append(items, MonitoringCitedArticle{ - ArticleID: articleID, - ArticleTitle: title, - PublishPlatform: publishPlatform, - CitationCount: citationCount, - CitationRate: divideAsPointer(citationCount, totalSampleCount), - SourceShare: divideAsPointer(citationCount, totalArticleCitationCount), + ArticleID: item.ArticleID, + ArticleTitle: item.ArticleTitle, + PublishPlatform: item.PublishPlatform, + CitationCount: item.CitationCount, + CitationRate: divideAsPointer(item.CitationCount, totalSampleCount), + SourceShare: divideAsPointer(item.CitationCount, totalArticleCitationCount), }) } + sort.Slice(items, func(left, right int) bool { + if items[left].CitationCount != items[right].CitationCount { + return items[left].CitationCount > items[right].CitationCount + } + return items[left].ArticleID < items[right].ArticleID + }) + if len(items) > 10 { + items = items[:10] + } return items, nil } @@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i return result, nil } + aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) + if err != nil { + return nil, err + } + rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_citations AS ( SELECT @@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i cf.run_id, cf.cited_url, cf.cited_title, + cf.normalized_url, cf.host, cf.registrable_domain, cf.site_key, - cf.article_id, cf.resolution_status, cf.resolution_confidence FROM monitoring_citation_facts cf @@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i fc.run_id, fc.cited_url, fc.cited_title, + fc.normalized_url, COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name, COALESCE(dm.mapped_site_key, fc.site_key) AS site_key, - fc.article_id, - alias.article_title_snapshot AS article_title, fc.resolution_status, fc.resolution_confidence FROM filtered_citations fc @@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i ON dm.host = fc.host AND dm.registrable_domain = fc.registrable_domain AND dm.site_key = fc.site_key - LEFT JOIN monitoring_article_url_aliases alias - ON alias.tenant_id = $1 AND alias.article_id = fc.article_id ORDER BY fc.run_id ASC, fc.id ASC `, tenantID, runIDs) if err != nil { @@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i var runID int64 var citedURL string var citedTitle sql.NullString + var normalizedURL string 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 { + if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &normalizedURL, &siteName, &siteKey, &resolutionStatus, &resolutionConfidence); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations") } siteName = repairMonitoringMojibake(siteName) if citedTitle.Valid { citedTitle.String = repairMonitoringMojibake(citedTitle.String) } - if articleTitle.Valid { - articleTitle.String = repairMonitoringMojibake(articleTitle.String) + var articleID *int64 + var articleTitle *string + if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok { + articleID = &alias.ArticleID + title := repairMonitoringMojibake(alias.ArticleTitle) + articleTitle = &title + resolutionStatus = "resolved" + resolutionConfidence = "high" } result[runID] = append(result[runID], MonitoringQuestionDetailCitation{ CitedURL: citedURL, @@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i SiteName: siteName, SiteKey: siteKey, FaviconURL: faviconURL(siteKey), - ArticleID: nullableInt64Value(articleID), - ArticleTitle: nullableStringValue(articleTitle), + ArticleID: articleID, + ArticleTitle: articleTitle, ResolutionStatus: resolutionStatus, ResolutionConfidence: resolutionConfidence, }) } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations") + } return result, nil } @@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te return []MonitoringQuestionCitationStats{}, nil } + aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) + if err != nil { + return nil, err + } + rows, err := s.monitoringPool.Query(ctx, ` WITH filtered_facts AS ( SELECT r.ai_platform_id, cf.id, + cf.cited_url, + cf.normalized_url, cf.host, cf.registrable_domain, - cf.site_key, - cf.article_id + cf.site_key FROM question_monitor_runs r JOIN monitoring_citation_facts cf ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id @@ -3090,49 +3128,20 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC LIMIT 1 ) dm ON TRUE - ), - grouped AS ( - SELECT - ff.ai_platform_id, - COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name, - COALESCE(dm.mapped_site_key, ff.site_key) AS site_key, - COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain, - COUNT(ff.id) AS citation_count, - COUNT(*) FILTER (WHERE ff.article_id IS NOT NULL AND ff.ai_platform_id <> 'qwen') AS content_citation_count - FROM filtered_facts ff - LEFT JOIN domain_mappings dm - ON dm.host = ff.host - AND dm.registrable_domain = ff.registrable_domain - AND dm.site_key = ff.site_key - GROUP BY - ff.ai_platform_id, - COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain), - COALESCE(dm.mapped_site_key, ff.site_key), - COALESCE(dm.mapped_domain, ff.site_key, ff.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 + ff.ai_platform_id, + ff.cited_url, + ff.normalized_url, + COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name, + COALESCE(dm.mapped_site_key, ff.site_key) AS site_key, + COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain + FROM filtered_facts ff + LEFT JOIN domain_mappings dm + ON dm.host = ff.host + AND dm.registrable_domain = ff.registrable_domain + AND dm.site_key = ff.site_key + ORDER BY ff.ai_platform_id ASC, ff.id ASC `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis") @@ -3148,12 +3157,12 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te totals := make(map[string]int64) for rows.Next() { var platformID string + var citedURL string + var normalizedURL string var siteName string var siteKey string var siteDomain string - var citationCount int64 - var contentCitationCount int64 - if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil { + if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis") } platformID = normalizeMonitoringPlatformID(platformID) @@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te SiteDomain: siteDomain, } } - item.CitationCount += citationCount - item.ContentCitationCount += contentCitationCount + item.CitationCount++ + if platformID != "qwen" { + if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok { + item.ContentCitationCount++ + } + } aggregates[key] = item - totals[platformID] += citationCount + totals[platformID]++ + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate question citation analysis") } items := make([]MonitoringQuestionCitationStats, 0, len(aggregates)) @@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te return []MonitoringQuestionContentCitation{}, nil } + aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID) + if err != nil { + return nil, err + } + 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.status = 'succeeded' - AND r.id = ANY($2) - 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 + cf.cited_url, + cf.normalized_url FROM question_monitor_runs r JOIN monitoring_citation_facts cf ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id - 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.status = 'succeeded' - AND cf.article_id IS NOT NULL AND r.ai_platform_id <> 'qwen' AND r.id = ANY($2) - GROUP BY cf.article_id, r.ai_platform_id - ORDER BY citation_count DESC, cf.article_id ASC `, tenantID, runIDs) if err != nil { return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations") @@ -3254,37 +3253,40 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation) totals := make(map[string]int64) 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 { + var citedURL string + var normalizedURL string + if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil { return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations") } platformID = normalizeMonitoringPlatformID(platformID) if platformID == "" { continue } + totals[platformID]++ + alias, ok := aliasIndex.Match(citedURL, normalizedURL) + if !ok { + continue + } key := contentCitationKey{ PlatformID: platformID, - ArticleID: articleID, + ArticleID: alias.ArticleID, } item := aggregates[key] if item.ArticleID == 0 { item = MonitoringQuestionContentCitation{ - ArticleID: articleID, - ArticleTitle: articleTitle, - PublishPlatform: publishPlatform, + ArticleID: alias.ArticleID, + ArticleTitle: alias.ArticleTitle, + PublishPlatform: alias.PublishPlatform, AIPlatformID: platformID, PlatformName: platformDisplayName(platformID), } } - item.CitationCount += citationCount + item.CitationCount++ aggregates[key] = item - totals[platformID] += citationCount - _ = totalCitationCount + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate content citations") } items := make([]MonitoringQuestionContentCitation, 0, len(aggregates)) diff --git a/server/internal/tenant/app/publish_record_support_test.go b/server/internal/tenant/app/publish_record_support_test.go index 877e2fe..affc17b 100644 --- a/server/internal/tenant/app/publish_record_support_test.go +++ b/server/internal/tenant/app/publish_record_support_test.go @@ -106,6 +106,16 @@ func TestMonitoringAliasOriginalURLUsesWangyihaoPublicURL(t *testing.T) { } } +func TestMonitoringCitationURLMatchKeyCollapsesNeteaseHostPrefix(t *testing.T) { + left := monitoringCitationURLMatchKey("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html?from=test") + right := monitoringCitationURLMatchKey("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html") + + const want = "163.com/dy/article/krpre8dj0556mcr0" + if left != want || right != want { + t.Fatalf("monitoringCitationURLMatchKey() = %q and %q; want %q", left, right, want) + } +} + func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) { if got := normalizePublishStatus("partial_success"); got != "partial_success" { t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got) diff --git a/server/internal/tenant/transport/monitoring_handler.go b/server/internal/tenant/transport/monitoring_handler.go index 5b85627..b100e60 100644 --- a/server/internal/tenant/transport/monitoring_handler.go +++ b/server/internal/tenant/transport/monitoring_handler.go @@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) { return } - data, svcErr := h.svc.CitationSummary(c.Request.Context(), days) + brandID, err := parseOptionalInt64(c.Query("brand_id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40031, "invalid_brand_id", "brand_id must be a number")) + return + } + + keywordID, err := parseOptionalInt64Pointer(c.Query("keyword_id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40031, "invalid_keyword_id", "keyword_id must be a number")) + return + } + + aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id")) + + data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID) if svcErr != nil { response.Error(c, svcErr) return