feat: paginate and search brand questions across selects
Frontend CI / Frontend (push) Successful in 3m19s
Backend CI / Backend (push) Successful in 14m30s

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:
2026-06-15 22:01:23 +08:00
parent 184ebfc4c2
commit 9ed857e159
13 changed files with 1030 additions and 189 deletions
+79 -8
View File
@@ -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) {
+8 -2
View File
@@ -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