feat: paginate tracking lists server-side
This commit is contained in:
@@ -31,6 +31,8 @@ import (
|
||||
const (
|
||||
defaultTrackingDays = 7
|
||||
maxTrackingDays = 30
|
||||
defaultMonitoringListPageSize = 10
|
||||
maxMonitoringListPageSize = 100
|
||||
monitoringCollectorType = "desktop"
|
||||
monitoringOnlineDuration = 20 * time.Minute
|
||||
monitoringCollectNowQuestionLimit = 5
|
||||
@@ -100,14 +102,29 @@ type MonitoringDashboardCompositeResponse struct {
|
||||
CitationWindow MonitoringDashboardWindow `json:"citation_window"`
|
||||
PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"`
|
||||
HotQuestions []MonitoringHotQuestion `json:"hot_questions"`
|
||||
HotQuestionsPage MonitoringPage `json:"hot_questions_page"`
|
||||
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
|
||||
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
||||
CitedArticlesPage MonitoringPage `json:"cited_articles_page"`
|
||||
}
|
||||
|
||||
type MonitoringCitationSummaryResponse struct {
|
||||
CitationWindow MonitoringDashboardWindow `json:"citation_window"`
|
||||
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
|
||||
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
||||
CitationWindow MonitoringDashboardWindow `json:"citation_window"`
|
||||
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
|
||||
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
||||
CitedArticlesPage MonitoringPage `json:"cited_articles_page"`
|
||||
}
|
||||
|
||||
type MonitoringPage struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type MonitoringPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Query string
|
||||
}
|
||||
|
||||
type MonitoringDashboardWindow struct {
|
||||
@@ -360,6 +377,7 @@ func (s *MonitoringService) DashboardComposite(
|
||||
days int,
|
||||
businessDate string,
|
||||
aiPlatformID *string,
|
||||
hotQuestionPage MonitoringPageRequest,
|
||||
) (*MonitoringDashboardCompositeResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
workspaceID := auth.CurrentWorkspaceID(ctx)
|
||||
@@ -421,7 +439,17 @@ func (s *MonitoringService) DashboardComposite(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, selectedPlatformID, derivedMetrics)
|
||||
hotQuestions, hotQuestionsPage, err := s.loadHotQuestionsPage(
|
||||
ctx,
|
||||
actor.TenantID,
|
||||
brand.ID,
|
||||
keywordID,
|
||||
questionID,
|
||||
endDate,
|
||||
selectedPlatformID,
|
||||
derivedMetrics,
|
||||
hotQuestionPage,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -436,8 +464,10 @@ func (s *MonitoringService) DashboardComposite(
|
||||
},
|
||||
PlatformBreakdown: platformBreakdown,
|
||||
HotQuestions: hotQuestions,
|
||||
HotQuestionsPage: hotQuestionsPage,
|
||||
CitationRanking: []MonitoringCitationRanking{},
|
||||
CitedArticles: []MonitoringCitedArticle{},
|
||||
CitedArticlesPage: emptyMonitoringPage(normalizeMonitoringPageRequest(MonitoringPageRequest{}, defaultMonitoringListPageSize)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -449,6 +479,7 @@ func (s *MonitoringService) CitationSummary(
|
||||
questionID *int64,
|
||||
businessDate string,
|
||||
aiPlatformID *string,
|
||||
citedArticlePage MonitoringPageRequest,
|
||||
) (*MonitoringCitationSummaryResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
workspaceID := auth.CurrentWorkspaceID(ctx)
|
||||
@@ -497,7 +528,7 @@ func (s *MonitoringService) CitationSummary(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID)
|
||||
citedArticles, citedArticlesPage, err := s.loadCitedArticlesPage(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID, citedArticlePage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -508,8 +539,9 @@ func (s *MonitoringService) CitationSummary(
|
||||
From: startDate.Format("2006-01-02"),
|
||||
To: endDate.Format("2006-01-02"),
|
||||
},
|
||||
CitationRanking: citationRanking,
|
||||
CitedArticles: citedArticles,
|
||||
CitationRanking: citationRanking,
|
||||
CitedArticles: citedArticles,
|
||||
CitedArticlesPage: citedArticlesPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1525,6 +1557,43 @@ func filterConfiguredQuestionsByQuestionID(questions []monitoringConfiguredQuest
|
||||
return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set")
|
||||
}
|
||||
|
||||
func normalizeMonitoringPageRequest(req MonitoringPageRequest, defaultPageSize int) MonitoringPageRequest {
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = defaultPageSize
|
||||
}
|
||||
if req.PageSize > maxMonitoringListPageSize {
|
||||
req.PageSize = maxMonitoringListPageSize
|
||||
}
|
||||
req.Query = strings.TrimSpace(req.Query)
|
||||
return req
|
||||
}
|
||||
|
||||
func emptyMonitoringPage(req MonitoringPageRequest) MonitoringPage {
|
||||
req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize)
|
||||
return MonitoringPage{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func monitoringPageFromRequest(req MonitoringPageRequest, total int64) MonitoringPage {
|
||||
req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize)
|
||||
return MonitoringPage{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
|
||||
func monitoringPageOffset(req MonitoringPageRequest) int {
|
||||
req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize)
|
||||
return (req.Page - 1) * req.PageSize
|
||||
}
|
||||
|
||||
func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error {
|
||||
if pruneMissing {
|
||||
if len(questions) == 0 {
|
||||
@@ -3291,6 +3360,91 @@ func (s *MonitoringService) loadHotQuestions(
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadHotQuestionsPage(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
keywordID *int64,
|
||||
questionID *int64,
|
||||
businessDate time.Time,
|
||||
aiPlatformID *string,
|
||||
derived monitoringDerivedMetrics,
|
||||
pageReq MonitoringPageRequest,
|
||||
) ([]MonitoringHotQuestion, MonitoringPage, error) {
|
||||
pageReq = normalizeMonitoringPageRequest(pageReq, defaultMonitoringListPageSize)
|
||||
|
||||
var total int64
|
||||
if err := s.businessPool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM brand_questions q
|
||||
JOIN brand_keywords k
|
||||
ON k.id = q.keyword_id
|
||||
AND k.tenant_id = q.tenant_id
|
||||
AND k.brand_id = q.brand_id
|
||||
AND k.deleted_at IS NULL
|
||||
AND k.status = 'active'
|
||||
WHERE q.tenant_id = $1
|
||||
AND q.brand_id = $2
|
||||
AND q.deleted_at IS NULL
|
||||
AND q.status = 'active'
|
||||
AND ($3::bigint IS NULL OR q.keyword_id = $3)
|
||||
AND ($4::bigint IS NULL OR q.id = $4)
|
||||
AND ($5::text = '' OR q.question_text ILIKE '%' || $5 || '%')
|
||||
`, tenantID, brandID, nullableInt64(keywordID), nullableInt64(questionID), pageReq.Query).Scan(&total); err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count hot questions")
|
||||
}
|
||||
|
||||
page := monitoringPageFromRequest(pageReq, total)
|
||||
if total == 0 {
|
||||
return []MonitoringHotQuestion{}, page, nil
|
||||
}
|
||||
|
||||
rows, err := s.businessPool.Query(ctx, `
|
||||
SELECT q.id, q.keyword_id, q.question_text
|
||||
FROM brand_questions q
|
||||
JOIN brand_keywords k
|
||||
ON k.id = q.keyword_id
|
||||
AND k.tenant_id = q.tenant_id
|
||||
AND k.brand_id = q.brand_id
|
||||
AND k.deleted_at IS NULL
|
||||
AND k.status = 'active'
|
||||
WHERE q.tenant_id = $1
|
||||
AND q.brand_id = $2
|
||||
AND q.deleted_at IS NULL
|
||||
AND q.status = 'active'
|
||||
AND ($3::bigint IS NULL OR q.keyword_id = $3)
|
||||
AND ($4::bigint IS NULL OR q.id = $4)
|
||||
AND ($5::text = '' OR q.question_text ILIKE '%' || $5 || '%')
|
||||
ORDER BY q.keyword_id ASC, q.id ASC
|
||||
LIMIT $6 OFFSET $7
|
||||
`, tenantID, brandID, nullableInt64(keywordID), nullableInt64(questionID), pageReq.Query, pageReq.PageSize, monitoringPageOffset(pageReq))
|
||||
if err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to load hot questions")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
questions := make([]monitoringConfiguredQuestion, 0, pageReq.PageSize)
|
||||
for rows.Next() {
|
||||
var item monitoringConfiguredQuestion
|
||||
if scanErr := rows.Scan(&item.ID, &item.KeywordID, &item.QuestionText); scanErr != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to parse hot questions")
|
||||
}
|
||||
item.QuestionHash = seededQuestionHash(item.QuestionText)
|
||||
questions = append(questions, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to iterate hot questions")
|
||||
}
|
||||
if len(questions) == 0 {
|
||||
return []MonitoringHotQuestion{}, page, nil
|
||||
}
|
||||
|
||||
items, err := s.loadHotQuestions(ctx, tenantID, brandID, questions, businessDate, aiPlatformID, derived)
|
||||
if err != nil {
|
||||
return nil, MonitoringPage{}, err
|
||||
}
|
||||
return items, page, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadCitationRanking(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
@@ -3552,13 +3706,128 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
}
|
||||
return items[left].ArticleID < items[right].ArticleID
|
||||
})
|
||||
if len(items) > 10 {
|
||||
items = items[:10]
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadCitedArticlesPage(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
startDate, endDate time.Time,
|
||||
aiPlatformID *string,
|
||||
pageReq MonitoringPageRequest,
|
||||
) ([]MonitoringCitedArticle, MonitoringPage, error) {
|
||||
pageReq = normalizeMonitoringPageRequest(pageReq, defaultMonitoringListPageSize)
|
||||
if questionIDs != nil && len(questionIDs) == 0 {
|
||||
return []MonitoringCitedArticle{}, emptyMonitoringPage(pageReq), nil
|
||||
}
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
startDateText := startDate.Format("2006-01-02")
|
||||
endDateText := endDate.Format("2006-01-02")
|
||||
|
||||
var totalSampleCount int64
|
||||
if err := s.monitoringPool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM question_monitor_runs r
|
||||
WHERE r.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
`, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)).Scan(&totalSampleCount); err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count cited article denominator")
|
||||
}
|
||||
|
||||
type citedArticleStats struct {
|
||||
Total int64
|
||||
TotalCitationCount int64
|
||||
}
|
||||
var stats citedArticleStats
|
||||
if err := s.monitoringPool.QueryRow(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT alias.article_id, COUNT(*)::bigint AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id
|
||||
AND alias.normalized_url = cf.normalized_url
|
||||
AND alias.confidence = 'high'
|
||||
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))
|
||||
GROUP BY alias.article_id
|
||||
)
|
||||
SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint
|
||||
FROM article_counts
|
||||
`, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)).Scan(&stats.Total, &stats.TotalCitationCount); err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count cited articles")
|
||||
}
|
||||
|
||||
page := monitoringPageFromRequest(pageReq, stats.Total)
|
||||
if stats.Total == 0 {
|
||||
return []MonitoringCitedArticle{}, page, nil
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT
|
||||
alias.article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
||||
COUNT(*)::bigint AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id
|
||||
AND alias.normalized_url = cf.normalized_url
|
||||
AND alias.confidence = 'high'
|
||||
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))
|
||||
GROUP BY alias.article_id
|
||||
)
|
||||
SELECT article_id, article_title, publish_platform, citation_count
|
||||
FROM article_counts
|
||||
ORDER BY citation_count DESC, article_id ASC
|
||||
LIMIT $8 OFFSET $9
|
||||
`, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs), pageReq.PageSize, monitoringPageOffset(pageReq))
|
||||
if err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]MonitoringCitedArticle, 0, pageReq.PageSize)
|
||||
for rows.Next() {
|
||||
var item MonitoringCitedArticle
|
||||
if scanErr := rows.Scan(&item.ArticleID, &item.ArticleTitle, &item.PublishPlatform, &item.CitationCount); scanErr != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
||||
}
|
||||
item.CitationRate = divideAsPointer(item.CitationCount, totalSampleCount)
|
||||
item.SourceShare = divideAsPointer(item.CitationCount, stats.TotalCitationCount)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
|
||||
}
|
||||
|
||||
return items, page, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadQuestionText(ctx context.Context, tenantID, brandID, questionID int64) (string, error) {
|
||||
var text string
|
||||
if err := s.businessPool.QueryRow(ctx, `
|
||||
|
||||
@@ -57,8 +57,13 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
|
||||
}
|
||||
|
||||
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
|
||||
hotQuestionPage, err := parseMonitoringPageRequest(c, "hot_questions", true)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, questionID, days, c.Query("business_date"), aiPlatformID)
|
||||
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, questionID, days, c.Query("business_date"), aiPlatformID, hotQuestionPage)
|
||||
if svcErr != nil {
|
||||
response.Error(c, svcErr)
|
||||
return
|
||||
@@ -92,8 +97,13 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
|
||||
}
|
||||
|
||||
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
|
||||
citedArticlePage, err := parseMonitoringPageRequest(c, "cited_articles", false)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, questionID, c.Query("business_date"), aiPlatformID)
|
||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, questionID, c.Query("business_date"), aiPlatformID, citedArticlePage)
|
||||
if svcErr != nil {
|
||||
response.Error(c, svcErr)
|
||||
return
|
||||
@@ -201,3 +211,25 @@ func parseOptionalStringPointer(raw string) *string {
|
||||
value := trimmed
|
||||
return &value
|
||||
}
|
||||
|
||||
func parseMonitoringPageRequest(c *gin.Context, prefix string, includeQuery bool) (app.MonitoringPageRequest, error) {
|
||||
req := app.MonitoringPageRequest{}
|
||||
if raw := c.Query(prefix + "_page"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return req, response.ErrBadRequest(40031, "invalid_page", prefix+"_page must be a positive number")
|
||||
}
|
||||
req.Page = value
|
||||
}
|
||||
if raw := c.Query(prefix + "_page_size"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return req, response.ErrBadRequest(40031, "invalid_page_size", prefix+"_page_size must be a positive number")
|
||||
}
|
||||
req.PageSize = value
|
||||
}
|
||||
if includeQuery {
|
||||
req.Query = strings.TrimSpace(c.Query(prefix + "_q"))
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user