feat: paginate and search brand questions across selects
Make GET /api/tenant/brands/:id/questions return a paginated QuestionListResponse (items/total/page/page_size) with optional `q` full-text filter, validated page/page_size query params, and per-params cache keys. Add a usePaginatedBrandQuestions composable backing the imitation, template-wizard, and tracking question selects with search-as-you-type and infinite scroll, plus ensure-loaded-by-id so a deep-linked or pre-selected question is fetched even when off the first page. Brands view now drives its questions table via server pagination. Also redesign the Tracking hot-questions and cited-articles lists (mention-rate badges/bars, metric groups, refreshed styling) and fall back to citation-fact article_id/title when no high-confidence URL alias matches, so cited articles surface even without an alias row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -474,10 +474,44 @@ type QuestionResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
|
||||
type QuestionListParams struct {
|
||||
KeywordID *int64
|
||||
Page int
|
||||
PageSize int
|
||||
Query string
|
||||
}
|
||||
|
||||
type QuestionListResponse struct {
|
||||
Items []QuestionResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
func normalizeQuestionListParams(params QuestionListParams) QuestionListParams {
|
||||
params.Query = strings.TrimSpace(params.Query)
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 {
|
||||
params.PageSize = 10
|
||||
}
|
||||
if params.PageSize > 100 {
|
||||
params.PageSize = 100
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (params QuestionListParams) offset() int {
|
||||
params = normalizeQuestionListParams(params)
|
||||
return (params.Page - 1) * params.PageSize
|
||||
}
|
||||
|
||||
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, keywordID), defaultCacheTTL(), func(loadCtx context.Context) ([]QuestionResponse, error) {
|
||||
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, keywordID)
|
||||
params = normalizeQuestionListParams(params)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*QuestionListResponse, error) {
|
||||
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, params)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -857,18 +891,44 @@ func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
|
||||
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
|
||||
params = normalizeQuestionListParams(params)
|
||||
|
||||
countQuery := `
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
|
||||
countArgs := []interface{}{brandID, tenantID}
|
||||
if params.KeywordID != nil {
|
||||
countQuery += ` AND q.keyword_id = $3`
|
||||
countArgs = append(countArgs, *params.KeywordID)
|
||||
}
|
||||
if params.Query != "" {
|
||||
countArgs = append(countArgs, "%"+params.Query+"%")
|
||||
countQuery += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(countArgs))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to count questions")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
|
||||
args := []interface{}{brandID, tenantID}
|
||||
|
||||
if keywordID != nil {
|
||||
if params.KeywordID != nil {
|
||||
query += ` AND q.keyword_id = $3`
|
||||
args = append(args, *keywordID)
|
||||
args = append(args, *params.KeywordID)
|
||||
}
|
||||
query += ` ORDER BY q.created_at DESC`
|
||||
if params.Query != "" {
|
||||
args = append(args, "%"+params.Query+"%")
|
||||
query += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(args))
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY q.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, params.PageSize, params.offset())
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -886,7 +946,18 @@ func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID
|
||||
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
if items == nil {
|
||||
items = []QuestionResponse{}
|
||||
}
|
||||
return &QuestionListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) {
|
||||
|
||||
@@ -116,11 +116,17 @@ func brandQuestionsCachePrefix(tenantID, brandID int64) string {
|
||||
return fmt.Sprintf("brand:questions:%d:%d:", tenantID, brandID)
|
||||
}
|
||||
|
||||
func brandQuestionsCacheKey(tenantID, brandID int64, keywordID *int64) string {
|
||||
func brandQuestionsCacheKey(tenantID, brandID int64, params QuestionListParams) string {
|
||||
return fmt.Sprintf("%s%s", brandQuestionsCachePrefix(tenantID, brandID), digestCacheKey(struct {
|
||||
KeywordID *int64 `json:"keyword_id,omitempty"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Query string `json:"q,omitempty"`
|
||||
}{
|
||||
KeywordID: keywordID,
|
||||
KeywordID: params.KeywordID,
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
Query: params.Query,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -3639,7 +3639,9 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
SELECT
|
||||
cf.cited_url,
|
||||
cf.normalized_url
|
||||
cf.normalized_url,
|
||||
NULLIF(cf.article_id, 0),
|
||||
NULLIF(cf.cited_title, '')
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
@@ -3668,22 +3670,40 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
for rows.Next() {
|
||||
var citedURL string
|
||||
var normalizedURL string
|
||||
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
|
||||
var factArticleID sql.NullInt64
|
||||
var factCitedTitle sql.NullString
|
||||
if scanErr := rows.Scan(&citedURL, &normalizedURL, &factArticleID, &factCitedTitle); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
||||
}
|
||||
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||
if !ok {
|
||||
|
||||
articleID := factArticleID.Int64
|
||||
if !factArticleID.Valid && ok {
|
||||
articleID = alias.ArticleID
|
||||
}
|
||||
if articleID <= 0 {
|
||||
continue
|
||||
}
|
||||
item := aggregates[alias.ArticleID]
|
||||
|
||||
item := aggregates[articleID]
|
||||
if item.ArticleID == 0 {
|
||||
item.ArticleID = alias.ArticleID
|
||||
item.ArticleTitle = alias.ArticleTitle
|
||||
item.PublishPlatform = alias.PublishPlatform
|
||||
item.ArticleID = articleID
|
||||
if ok && strings.TrimSpace(alias.ArticleTitle) != "" {
|
||||
item.ArticleTitle = alias.ArticleTitle
|
||||
} else if factCitedTitle.Valid && strings.TrimSpace(factCitedTitle.String) != "" {
|
||||
item.ArticleTitle = factCitedTitle.String
|
||||
} else {
|
||||
item.ArticleTitle = "未命名文章"
|
||||
}
|
||||
if ok && strings.TrimSpace(alias.PublishPlatform) != "" {
|
||||
item.PublishPlatform = alias.PublishPlatform
|
||||
} else {
|
||||
item.PublishPlatform = "已发布内容"
|
||||
}
|
||||
}
|
||||
item.CitationCount++
|
||||
totalArticleCitationCount++
|
||||
aggregates[alias.ArticleID] = item
|
||||
aggregates[articleID] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
|
||||
@@ -3748,11 +3768,12 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
var stats citedArticleStats
|
||||
if err := s.monitoringPool.QueryRow(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT alias.article_id, COUNT(*)::bigint AS citation_count
|
||||
SELECT COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS 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
|
||||
LEFT JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id
|
||||
AND alias.normalized_url = cf.normalized_url
|
||||
AND alias.confidence = 'high'
|
||||
@@ -3764,7 +3785,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
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
|
||||
AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
|
||||
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
|
||||
)
|
||||
SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint
|
||||
FROM article_counts
|
||||
@@ -3780,14 +3802,14 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT
|
||||
alias.article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
||||
COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), MAX(NULLIF(cf.cited_title, '')), '未命名文章') 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
|
||||
LEFT JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id
|
||||
AND alias.normalized_url = cf.normalized_url
|
||||
AND alias.confidence = 'high'
|
||||
@@ -3799,7 +3821,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
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
|
||||
AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
|
||||
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
|
||||
)
|
||||
SELECT article_id, article_title, publish_platform, citation_count
|
||||
FROM article_counts
|
||||
|
||||
@@ -156,14 +156,39 @@ func (h *BrandHandler) DeleteKeyword(c *gin.Context) {
|
||||
|
||||
// Questions
|
||||
func (h *BrandHandler) ListQuestions(c *gin.Context) {
|
||||
brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
brandID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "brand id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
var keywordID *int64
|
||||
if v := c.Query("keyword_id"); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
keywordID = &id
|
||||
id, parseErr := strconv.ParseInt(v, 10, 64)
|
||||
if parseErr != nil || id <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "keyword_id must be a positive integer"))
|
||||
return
|
||||
}
|
||||
keywordID = &id
|
||||
}
|
||||
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, keywordID)
|
||||
|
||||
page, err := parsePositiveIntQuery(c, "page", 1)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
|
||||
return
|
||||
}
|
||||
pageSize, err := parsePositiveIntQuery(c, "page_size", 10)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, app.QuestionListParams{
|
||||
KeywordID: keywordID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Query: c.Query("q"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
@@ -171,6 +196,21 @@ func (h *BrandHandler) ListQuestions(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func parsePositiveIntQuery(c *gin.Context, key string, fallback int) (int, error) {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (h *BrandHandler) CreateQuestion(c *gin.Context) {
|
||||
brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var req app.QuestionRequest
|
||||
|
||||
Reference in New Issue
Block a user