Files
geo/server/internal/tenant/app/monitoring_service.go
T
root 5ff2e2e74c refactor(tenant): drop legacy plugin_installations, migrate monitoring to desktop_clients
Hard cutover from the browser-extension plugin flow to desktop clients:
remove plugin_installations/plugin_sessions tables and related service,
handler, router, and generated model code; migrate monitoring quotas
and collector types to desktop_clients (UUID primary_client_id);
recreate platform_access_snapshots keyed by client_id; update dev-seed
and callback types accordingly; mark legacy design docs as historical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:52:35 +08:00

2811 lines
91 KiB
Go

package app
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
defaultTrackingDays = 7
maxTrackingDays = 30
monitoringCollectorType = "desktop"
monitoringOnlineDuration = 20 * time.Minute
monitoringCollectNowQuestionLimit = 5
)
type MonitoringService struct {
businessPool *pgxpool.Pool
monitoringPool *pgxpool.Pool
}
func NewMonitoringService(businessPool, monitoringPool *pgxpool.Pool) *MonitoringService {
return &MonitoringService{
businessPool: businessPool,
monitoringPool: monitoringPool,
}
}
type MonitoringDashboardCompositeResponse struct {
Overview MonitoringOverview `json:"overview"`
PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"`
BrandTimeBuckets []MonitoringTimeBucket `json:"brand_time_buckets"`
HotQuestions []MonitoringHotQuestion `json:"hot_questions"`
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
}
type MonitoringOverview struct {
CollectionMode string `json:"collection_mode"`
MentionRate *float64 `json:"mention_rate"`
Top1MentionRate *float64 `json:"top1_mention_rate"`
FirstRecommendRate *float64 `json:"first_recommend_rate"`
PositiveMentionRate *float64 `json:"positive_mention_rate"`
CitationRate *float64 `json:"citation_rate"`
DesiredSampleCount int64 `json:"desired_sample_count"`
PlannedSampleCount int64 `json:"planned_sample_count"`
ActualSampleCount int64 `json:"actual_sample_count"`
CoverageRate *float64 `json:"coverage_rate"`
ConfidenceLevel string `json:"confidence_level"`
LastSampledAt *string `json:"last_sampled_at"`
LastTriggerSource *string `json:"last_trigger_source"`
PrevSnapshotMentionRate *float64 `json:"prev_snapshot_mention_rate"`
PrevSnapshotTop1MentionRate *float64 `json:"prev_snapshot_top1_mention_rate"`
PrevSnapshotFirstRecommendRate *float64 `json:"prev_snapshot_first_recommend_rate"`
PrevSnapshotPositiveMentionRate *float64 `json:"prev_snapshot_positive_mention_rate"`
}
type MonitoringPlatformDaily struct {
AIPlatformID string `json:"ai_platform_id"`
PlatformName string `json:"platform_name"`
MentionRate *float64 `json:"mention_rate"`
Top1MentionRate *float64 `json:"top1_mention_rate"`
PlatformSampleStatus string `json:"platform_sample_status"`
ActualSampleCount int64 `json:"actual_sample_count"`
LastSampledAt *string `json:"last_sampled_at"`
}
type MonitoringTimeBucket struct {
Date string `json:"date"`
SampleStatus string `json:"sample_status"`
MetricValue *float64 `json:"metric_value"`
MentionRate *float64 `json:"mention_rate"`
Top1MentionRate *float64 `json:"top1_mention_rate"`
FirstRecommendRate *float64 `json:"first_recommend_rate"`
PositiveMentionRate *float64 `json:"positive_mention_rate"`
CitationRate *float64 `json:"citation_rate"`
PlannedSampleCount int64 `json:"planned_sample_count"`
ActualSampleCount int64 `json:"actual_sample_count"`
CoverageRate *float64 `json:"coverage_rate"`
TriggerSource *string `json:"trigger_source"`
SnapshotUpdatedAt *string `json:"snapshot_updated_at"`
}
type MonitoringHotQuestion struct {
QuestionID int64 `json:"question_id"`
QuestionHash string `json:"question_hash"`
QuestionText string `json:"question_text"`
MentionRate *float64 `json:"mention_rate"`
LastSampledAt *string `json:"last_sampled_at"`
}
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"`
}
type MonitoringCitedArticle struct {
ArticleID int64 `json:"article_id"`
ArticleTitle string `json:"article_title"`
PublishPlatform string `json:"publish_platform"`
CitationCount int64 `json:"citation_count"`
CitationRate *float64 `json:"citation_rate"`
}
type MonitoringQuestionDetailResponse struct {
QuestionID int64 `json:"question_id"`
QuestionHash string `json:"question_hash"`
QuestionText string `json:"question_text"`
TimeWindow MonitoringQuestionDetailTimeWindow `json:"time_window"`
Platforms []MonitoringQuestionDetailPlatform `json:"platforms"`
CitationAnalysis []MonitoringQuestionCitationStats `json:"citation_analysis"`
ContentCitations []MonitoringQuestionContentCitation `json:"content_citations"`
}
type MonitoringQuestionDetailTimeWindow struct {
DateFrom string `json:"date_from"`
DateTo string `json:"date_to"`
}
type MonitoringQuestionDetailPlatform struct {
AIPlatformID string `json:"ai_platform_id"`
PlatformName string `json:"platform_name"`
SampleStatus string `json:"sample_status"`
RunID *int64 `json:"run_id"`
SampledAt *string `json:"sampled_at"`
ProviderModel *string `json:"provider_model"`
AnswerText *string `json:"answer_text"`
BrandMentioned *bool `json:"brand_mentioned"`
BrandMentionPosition *string `json:"brand_mention_position"`
Citations []MonitoringQuestionDetailCitation `json:"citations"`
}
type MonitoringQuestionDetailCitation struct {
CitedURL string `json:"cited_url"`
CitedTitle *string `json:"cited_title"`
SiteName string `json:"site_name"`
SiteKey string `json:"site_key"`
FaviconURL string `json:"favicon_url"`
ArticleID *int64 `json:"article_id"`
ArticleTitle *string `json:"article_title,omitempty"`
ResolutionStatus string `json:"resolution_status"`
ResolutionConfidence string `json:"resolution_confidence"`
}
type MonitoringQuestionCitationStats struct {
AIPlatformID string `json:"ai_platform_id"`
PlatformName string `json:"platform_name"`
SiteName string `json:"site_name"`
SiteKey string `json:"site_key"`
SiteDomain string `json:"site_domain"`
CitationCount int64 `json:"citation_count"`
CitationRate *float64 `json:"citation_rate"`
ContentCitationCount int64 `json:"content_citation_count"`
}
type MonitoringQuestionContentCitation struct {
ArticleID int64 `json:"article_id"`
ArticleTitle string `json:"article_title"`
PublishPlatform string `json:"publish_platform"`
AIPlatformID string `json:"ai_platform_id"`
PlatformName string `json:"platform_name"`
CitationCount int64 `json:"citation_count"`
CitationRate *float64 `json:"citation_rate"`
}
type MonitoringCollectNowResponse struct {
CollectionMode string `json:"collection_mode"`
RefreshedTaskCount int64 `json:"refreshed_task_count"`
CreatedTaskCount int64 `json:"created_task_count"`
LeasedTaskCount int64 `json:"leased_task_count"`
CompletedTaskCount int64 `json:"completed_task_count"`
HasEffectiveSnapshot bool `json:"has_effective_snapshot"`
Message string `json:"message"`
}
type monitoringQuotaConfig struct {
CollectionMode string
PrimaryClientID *uuid.UUID
EnabledPlatforms []string
}
type monitoringBrandIdentity struct {
ID int64
Name string
}
type monitoringAccessState struct {
AccessStatus string
}
type monitoringLatestPlatformRun struct {
PlatformID string
RunID int64
CompletedAt sql.NullTime
ProviderModel sql.NullString
AnswerText sql.NullString
BrandMentioned sql.NullBool
BrandMentionPosition sql.NullString
}
type monitoringDerivedRateStats struct {
Total int64
MentionedCount int64
Top1Count int64
FirstRecommend int64
PositiveMentions int64
}
type monitoringDerivedMetrics struct {
ByDate map[string]monitoringDerivedRateStats
ByDatePlatform map[string]map[string]monitoringDerivedRateStats
ByDateQuestion map[string]map[int64]monitoringDerivedRateStats
ByQuestion map[int64]monitoringDerivedRateStats
}
type monitoringPlatformMetadata struct {
ID string
Name string
}
type monitoringConfiguredQuestion struct {
ID int64
KeywordID int64
QuestionText string
QuestionHash []byte
}
var defaultMonitoringPlatforms = []monitoringPlatformMetadata{
{ID: "deepseek", Name: "DeepSeek"},
{ID: "qwen", Name: "通义千问"},
{ID: "doubao", Name: "豆包"},
}
var monitoringPlatformNames = map[string]string{
"deepseek": "DeepSeek",
"qwen": "通义千问",
"doubao": "豆包",
"kimi": "Kimi",
"yuanbao": "腾讯元宝",
}
func (s *MonitoringService) DashboardComposite(
ctx context.Context,
brandID int64,
keywordID *int64,
days int,
businessDate string,
aiPlatformID *string,
) (*MonitoringDashboardCompositeResponse, error) {
actor := auth.MustActor(ctx)
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
}
}
quota, err := s.loadQuota(ctx, actor.TenantID)
if err != nil {
return nil, err
}
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
if err != nil {
return nil, err
}
questionIDs := configuredQuestionIDs(configuredQuestions)
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
filteredPlatforms := filterMonitoringPlatforms(platforms, aiPlatformID)
startDate, endDate, err := resolveDashboardDateWindow(days, businessDate)
if err != nil {
return nil, err
}
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate, aiPlatformID)
if err != nil {
return nil, err
}
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, aiPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryClientID, endDate)
if err != nil {
return nil, err
}
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
if err != nil {
return nil, err
}
timeBuckets, err := s.loadTimeBuckets(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, aiPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, aiPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, accessStates, aiPlatformID)
if err != nil {
return nil, err
}
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, aiPlatformID)
if err != nil {
return nil, err
}
return &MonitoringDashboardCompositeResponse{
Overview: overview,
PlatformBreakdown: platformBreakdown,
BrandTimeBuckets: timeBuckets,
HotQuestions: hotQuestions,
CitationRanking: citationRanking,
CitedArticles: citedArticles,
}, nil
}
func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questionID int64, dateFrom, dateTo string, questionHash string, aiPlatformID *string) (*MonitoringQuestionDetailResponse, error) {
actor := auth.MustActor(ctx)
brand, err := s.resolveBrand(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
fromDate, toDate, err := parseDetailDateRange(dateFrom, dateTo)
if err != nil {
return nil, err
}
quota, err := s.loadQuota(ctx, actor.TenantID)
if err != nil {
return nil, err
}
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
questionText, err := s.loadQuestionText(ctx, actor.TenantID, brand.ID, questionID)
if err != nil {
return nil, err
}
hashBytes := []byte(nil)
if strings.TrimSpace(questionHash) != "" {
hashBytes, err = decodeQuestionHash(questionHash)
if err != nil {
return nil, response.ErrBadRequest(40031, "invalid_question_hash", "question_hash must be a v1 hex digest")
}
} else {
hashBytes, err = s.loadLatestQuestionHash(ctx, actor.TenantID, brand.ID, questionID, fromDate, toDate)
if err != nil {
return nil, err
}
}
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryClientID, toDate)
if err != nil {
return nil, err
}
latestRuns, err := s.loadLatestPlatformRuns(ctx, actor.TenantID, brand.ID, brand.Name, questionID, hashBytes, fromDate, toDate, aiPlatformID)
if err != nil {
return nil, err
}
runIDs := make([]int64, 0, len(latestRuns))
for _, item := range latestRuns {
runIDs = append(runIDs, item.RunID)
}
citationMap, err := s.loadCitationsForRuns(ctx, actor.TenantID, runIDs)
if err != nil {
return nil, err
}
platformPayload := make([]MonitoringQuestionDetailPlatform, 0, len(platforms))
for _, platform := range platforms {
if aiPlatformID != nil && strings.TrimSpace(*aiPlatformID) != "" && platform.ID != strings.TrimSpace(*aiPlatformID) {
continue
}
if run, ok := latestRuns[platform.ID]; ok {
runID := run.RunID
platformPayload = append(platformPayload, MonitoringQuestionDetailPlatform{
AIPlatformID: platform.ID,
PlatformName: platform.Name,
SampleStatus: "sampled",
RunID: &runID,
SampledAt: formatNullTime(run.CompletedAt),
ProviderModel: stringPointer(run.ProviderModel),
AnswerText: stringPointer(run.AnswerText),
BrandMentioned: boolPointer(run.BrandMentioned),
BrandMentionPosition: stringPointer(run.BrandMentionPosition),
Citations: citationMap[run.RunID],
})
continue
}
platformPayload = append(platformPayload, MonitoringQuestionDetailPlatform{
AIPlatformID: platform.ID,
PlatformName: platform.Name,
SampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]),
Citations: []MonitoringQuestionDetailCitation{},
})
}
citationAnalysis, err := s.loadQuestionCitationAnalysis(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, aiPlatformID)
if err != nil {
return nil, err
}
contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, aiPlatformID)
if err != nil {
return nil, err
}
return &MonitoringQuestionDetailResponse{
QuestionID: questionID,
QuestionHash: encodeQuestionHash(hashBytes),
QuestionText: questionText,
TimeWindow: MonitoringQuestionDetailTimeWindow{
DateFrom: fromDate.Format("2006-01-02"),
DateTo: toDate.Format("2006-01-02"),
},
Platforms: platformPayload,
CitationAnalysis: citationAnalysis,
ContentCitations: contentCitations,
}, nil
}
func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywordID *int64) (*MonitoringCollectNowResponse, error) {
actor := auth.MustActor(ctx)
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
}
}
quota, err := s.loadQuota(ctx, actor.TenantID)
if err != nil {
return nil, err
}
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
if err != nil {
return nil, err
}
limitApplied := false
if len(configuredQuestions) > monitoringCollectNowQuestionLimit {
configuredQuestions = configuredQuestions[:monitoringCollectNowQuestionLimit]
limitApplied = true
}
if len(configuredQuestions) == 0 {
return &MonitoringCollectNowResponse{
CollectionMode: quota.CollectionMode,
RefreshedTaskCount: 0,
CreatedTaskCount: 0,
LeasedTaskCount: 0,
CompletedTaskCount: 0,
HasEffectiveSnapshot: true,
Message: "当前关键词下暂无品牌库问题,未创建采集任务",
}, nil
}
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
questionIDs := configuredQuestionIDs(configuredQuestions)
clientID, err := s.resolveCollectNowClientID(ctx, actor.TenantID, actor.PrimaryWorkspaceID, quota.PrimaryClientID)
if err != nil {
return nil, err
}
if clientID == nil {
return nil, response.ErrConflict(40941, "desktop_client_offline", "primary monitoring desktop client is offline")
}
if err := s.ensureClientOnline(ctx, actor.TenantID, actor.PrimaryWorkspaceID, *clientID); err != nil {
return nil, err
}
now := time.Now().UTC()
todayTime, today := monitoringBusinessDayAndDateAt(now)
tx, err := s.monitoringPool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50041, "begin_failed", "failed to start monitoring collect-now transaction")
}
defer tx.Rollback(ctx)
if _, err := expireMonitoringLeasedTasks(ctx, tx, &actor.TenantID, now); err != nil {
return nil, err
}
if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil); err != nil {
return nil, err
}
refreshedCount, createdCount, err := s.ensureCollectNowTasks(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, platforms, todayTime)
if err != nil {
return nil, err
}
var leasedCount int64
var completedCount int64
if err := tx.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE status = 'leased') AS leased_count,
COUNT(*) FILTER (WHERE status = 'completed') AS completed_count
FROM monitoring_collect_tasks
WHERE tenant_id = $1
AND brand_id = $2
AND collector_type = $3
AND business_date = $4::date
AND question_id = ANY($5)
`, actor.TenantID, brand.ID, monitoringCollectorType, today, questionIDs).Scan(&leasedCount, &completedCount); err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring tasks")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50041, "commit_failed", "failed to commit monitoring collect-now")
}
hasEffectiveSnapshot := false
message := "已触发所选关键词立即采集,正在后台执行"
switch {
case createdCount > 0 && refreshedCount > 0:
message = "已补种并重置所选关键词今日任务,正在后台执行"
case createdCount > 0:
message = "已补种所选关键词的采集任务,正在后台执行"
case refreshedCount > 0:
message = "已重置所选关键词今日任务,正在后台执行"
case leasedCount > 0:
hasEffectiveSnapshot = true
message = "所选关键词已有执行中的任务,已继续在后台执行"
case completedCount > 0:
message = "已触发所选关键词重新采集,新的结果会覆盖今日结果"
default:
hasEffectiveSnapshot = true
message = "所选关键词暂无可重新调度的任务"
}
if limitApplied && (createdCount > 0 || refreshedCount > 0) {
message = fmt.Sprintf("%s(本次最多执行 %d 个问题)", message, monitoringCollectNowQuestionLimit)
}
return &MonitoringCollectNowResponse{
CollectionMode: quota.CollectionMode,
RefreshedTaskCount: refreshedCount,
CreatedTaskCount: createdCount,
LeasedTaskCount: leasedCount,
CompletedTaskCount: completedCount,
HasEffectiveSnapshot: hasEffectiveSnapshot,
Message: message,
}, nil
}
func (s *MonitoringService) resolveCollectNowClientID(ctx context.Context, tenantID, workspaceID int64, preferred *uuid.UUID) (*uuid.UUID, error) {
if preferred != nil {
online, err := s.isClientOnline(ctx, tenantID, workspaceID, *preferred)
if err != nil {
return nil, err
}
if online {
return preferred, nil
}
}
fallbackID, err := s.findLatestOnlineClient(ctx, tenantID, workspaceID)
if err != nil {
return nil, err
}
if fallbackID == nil {
return nil, nil
}
if preferred == nil || *preferred != *fallbackID {
if err := s.persistPrimaryClientID(ctx, tenantID, workspaceID, *fallbackID); err != nil {
return nil, err
}
}
return fallbackID, nil
}
func (s *MonitoringService) findLatestOnlineClient(ctx context.Context, tenantID, workspaceID int64) (*uuid.UUID, error) {
var clientID uuid.UUID
err := s.businessPool.QueryRow(ctx, `
SELECT id
FROM desktop_clients
WHERE tenant_id = $1
AND workspace_id = $2
AND revoked_at IS NULL
AND last_seen_at IS NOT NULL
AND last_seen_at >= NOW() - $3::interval
ORDER BY last_seen_at DESC, created_at DESC, id DESC
LIMIT 1
`, tenantID, workspaceID, formatPgInterval(monitoringOnlineDuration)).Scan(&clientID)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, response.ErrInternal(50041, "query_failed", "failed to inspect online monitoring desktop client")
}
return &clientID, nil
}
func (s *MonitoringService) persistPrimaryClientID(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) error {
if _, err := s.businessPool.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas (
tenant_id, workspace_id, primary_client_id
)
VALUES (
$1,
$2,
$3
)
ON CONFLICT (tenant_id) DO UPDATE
SET workspace_id = EXCLUDED.workspace_id,
primary_client_id = EXCLUDED.primary_client_id,
updated_at = NOW()
`, tenantID, workspaceID, clientID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring desktop client")
}
return nil
}
func formatPgInterval(duration time.Duration) string {
return fmt.Sprintf("%d seconds", int64(duration/time.Second))
}
func (s *MonitoringService) resolveBrand(ctx context.Context, tenantID, brandID int64) (*monitoringBrandIdentity, error) {
targetBrandID := brandID
if targetBrandID == 0 {
if err := s.businessPool.QueryRow(ctx, `
SELECT id
FROM brands
WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY created_at ASC
LIMIT 1
`, tenantID).Scan(&targetBrandID); err != nil {
if err == pgx.ErrNoRows {
return nil, response.ErrNotFound(40441, "brand_not_found", "no brand found for tenant")
}
return nil, response.ErrInternal(50041, "query_failed", "failed to resolve brand")
}
}
brand := &monitoringBrandIdentity{}
if err := s.businessPool.QueryRow(ctx, `
SELECT id, name
FROM brands
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, targetBrandID, tenantID).Scan(&brand.ID, &brand.Name); err != nil {
if err == pgx.ErrNoRows {
return nil, response.ErrNotFound(40441, "brand_not_found", "brand not found")
}
return nil, response.ErrInternal(50041, "query_failed", "failed to resolve brand")
}
return brand, nil
}
func (s *MonitoringService) ensureKeywordBelongsToBrand(ctx context.Context, tenantID, brandID, keywordID int64) error {
var exists bool
if err := s.businessPool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM brand_keywords
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
)
`, keywordID, tenantID, brandID).Scan(&exists); err != nil {
return response.ErrInternal(50041, "query_failed", "failed to validate keyword")
}
if !exists {
return response.ErrBadRequest(40041, "invalid_keyword", "keyword does not belong to the selected brand")
}
return nil
}
func (s *MonitoringService) loadConfiguredQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]monitoringConfiguredQuestion, error) {
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)
ORDER BY q.keyword_id ASC, q.id ASC
`, tenantID, brandID, nullableInt64(keywordID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load configured monitoring questions")
}
defer rows.Close()
items := make([]monitoringConfiguredQuestion, 0)
for rows.Next() {
var item monitoringConfiguredQuestion
if scanErr := rows.Scan(&item.ID, &item.KeywordID, &item.QuestionText); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse configured monitoring questions")
}
item.QuestionHash = seededQuestionHash(item.QuestionText)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate configured monitoring questions")
}
return items, nil
}
func configuredQuestionIDs(questions []monitoringConfiguredQuestion) []int64 {
ids := make([]int64, 0, len(questions))
for _, item := range questions {
ids = append(ids, item.ID)
}
return ids
}
func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error {
if pruneMissing {
if len(questions) == 0 {
if _, err := tx.Exec(ctx, `
UPDATE monitoring_question_config_snapshots
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, tenantID, brandID); err != nil {
return response.ErrInternal(50041, "snapshot_prune_failed", "failed to prune monitoring question snapshots")
}
} else if _, err := tx.Exec(ctx, `
UPDATE monitoring_question_config_snapshots
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
AND NOT (question_id = ANY($3))
`, tenantID, brandID, configuredQuestionIDs(questions)); err != nil {
return response.ErrInternal(50041, "snapshot_prune_failed", "failed to prune monitoring question snapshots")
}
}
for _, question := range questions {
if _, err := tx.Exec(ctx, `
UPDATE monitoring_question_config_snapshots
SET superseded_at = COALESCE(superseded_at, NOW()),
deleted_at = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND question_id = $3
AND question_hash <> $4
AND superseded_at IS NULL
`, tenantID, brandID, question.ID, question.QuestionHash); err != nil {
return response.ErrInternal(50041, "snapshot_update_failed", "failed to supersede monitoring question snapshot")
}
if _, err := tx.Exec(ctx, `
INSERT INTO monitoring_question_config_snapshots (
tenant_id, brand_id, question_id, keyword_id, question_hash,
question_text_snapshot, monitor_enabled, projected_at
)
VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW())
ON CONFLICT (tenant_id, question_id, question_hash)
DO UPDATE SET
brand_id = EXCLUDED.brand_id,
keyword_id = EXCLUDED.keyword_id,
question_text_snapshot = EXCLUDED.question_text_snapshot,
monitor_enabled = TRUE,
superseded_at = NULL,
deleted_at = NULL,
projected_at = NOW(),
updated_at = NOW()
`, tenantID, brandID, question.ID, question.KeywordID, question.QuestionHash, question.QuestionText); err != nil {
return response.ErrInternal(50041, "snapshot_upsert_failed", "failed to persist monitoring question snapshot")
}
}
return nil
}
func (s *MonitoringService) ensureCollectNowTasks(
ctx context.Context,
tx pgx.Tx,
tenantID, brandID int64,
questions []monitoringConfiguredQuestion,
platforms []monitoringPlatformMetadata,
businessDate time.Time,
) (int64, int64, error) {
const runMode = "plugin_standard"
dateText := businessDate.Format("2006-01-02")
var refreshedCount int64
var createdCount int64
for _, question := range questions {
for _, platform := range platforms {
tag, err := tx.Exec(ctx, `
UPDATE monitoring_collect_tasks
SET question_hash = $7,
status = 'pending',
planned_at = NOW(),
trigger_source = 'manual',
lease_token_hash = NULL,
leased_to_executor = NULL,
leased_at = NULL,
lease_expires_at = NULL,
callback_received_at = NULL,
completed_at = NULL,
skip_reason = NULL,
request_payload_json = NULL,
error_message = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND question_id = $3
AND ai_platform_id = $4
AND collector_type = $5
AND run_mode = $6
AND business_date = $8::date
AND status IN ('pending', 'expired', 'failed', 'completed', 'skipped')
`, tenantID, brandID, question.ID, platform.ID, monitoringCollectorType, runMode, question.QuestionHash, dateText)
if err != nil {
return 0, 0, response.ErrInternal(50041, "update_failed", "failed to refresh monitoring tasks")
}
refreshedCount += tag.RowsAffected()
tag, err = tx.Exec(ctx, `
INSERT INTO monitoring_collect_tasks (
tenant_id, brand_id, question_id, question_hash, ai_platform_id,
collector_type, trigger_source, run_mode, business_date, planned_at, status
)
VALUES (
$1, $2, $3, $4, $5,
$6, 'manual', $7, $8::date, NOW(), 'pending'
)
ON CONFLICT (
tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date
)
DO NOTHING
`, tenantID, brandID, question.ID, question.QuestionHash, platform.ID, monitoringCollectorType, runMode, dateText)
if err != nil {
return 0, 0, response.ErrInternal(50041, "insert_failed", "failed to create monitoring tasks")
}
createdCount += tag.RowsAffected()
}
}
return refreshedCount, createdCount, nil
}
func (s *MonitoringService) loadQuota(ctx context.Context, tenantID int64) (monitoringQuotaConfig, error) {
cfg := monitoringQuotaConfig{
CollectionMode: monitoringCollectorType,
EnabledPlatforms: []string{"deepseek", "qwen", "doubao"},
}
var enabledJSON []byte
var clientID uuid.NullUUID
err := s.businessPool.QueryRow(ctx, `
SELECT collection_mode, enabled_platforms, primary_client_id
FROM tenant_monitoring_quotas
WHERE tenant_id = $1
`, tenantID).Scan(&cfg.CollectionMode, &enabledJSON, &clientID)
if err != nil {
if err == pgx.ErrNoRows {
return cfg, nil
}
return cfg, response.ErrInternal(50041, "query_failed", "failed to load monitoring quota")
}
if len(enabledJSON) > 0 {
var enabled []string
if jsonErr := json.Unmarshal(enabledJSON, &enabled); jsonErr == nil && len(enabled) > 0 {
cfg.EnabledPlatforms = enabled
}
}
if clientID.Valid {
id := clientID.UUID
cfg.PrimaryClientID = &id
}
if strings.TrimSpace(cfg.CollectionMode) == "" {
cfg.CollectionMode = monitoringCollectorType
}
return cfg, nil
}
func newMonitoringDerivedMetrics() monitoringDerivedMetrics {
return monitoringDerivedMetrics{
ByDate: make(map[string]monitoringDerivedRateStats),
ByDatePlatform: make(map[string]map[string]monitoringDerivedRateStats),
ByDateQuestion: make(map[string]map[int64]monitoringDerivedRateStats),
ByQuestion: make(map[int64]monitoringDerivedRateStats),
}
}
func (stats *monitoringDerivedRateStats) add(summary monitoringAnswerParseSummary) {
stats.Total++
if summary.BrandMentioned {
stats.MentionedCount++
}
if summary.BrandMentionPosition == "top1" {
stats.Top1Count++
}
if summary.FirstRecommended {
stats.FirstRecommend++
}
if summary.SentimentLabel == "positive" {
stats.PositiveMentions++
}
}
func (stats monitoringDerivedRateStats) hasSamples() bool {
return stats.Total > 0
}
func (stats monitoringDerivedRateStats) mentionRate() *float64 {
return divideAsPointer(stats.MentionedCount, stats.Total)
}
func averageDerivedRateByPlatform(
statsByPlatform map[string]monitoringDerivedRateStats,
extractor func(monitoringDerivedRateStats) *float64,
) *float64 {
if len(statsByPlatform) == 0 {
return nil
}
total := 0.0
count := 0
for _, stats := range statsByPlatform {
value := extractor(stats)
if value == nil {
continue
}
total += *value
count++
}
if count == 0 {
return nil
}
average := total / float64(count)
return &average
}
func (stats monitoringDerivedRateStats) top1MentionRate() *float64 {
return divideAsPointer(stats.Top1Count, stats.Total)
}
func (stats monitoringDerivedRateStats) firstRecommendRate() *float64 {
return divideAsPointer(stats.FirstRecommend, stats.Total)
}
func (stats monitoringDerivedRateStats) positiveMentionRate() *float64 {
return divideAsPointer(stats.PositiveMentions, stats.Total)
}
func (s *MonitoringService) loadDerivedParseMetrics(
ctx context.Context,
tenantID, brandID int64,
brandName string,
startDate, endDate time.Time,
aiPlatformID *string,
) (monitoringDerivedMetrics, error) {
metrics := newMonitoringDerivedMetrics()
rows, err := s.monitoringPool.Query(ctx, `
SELECT
r.business_date,
r.ai_platform_id,
r.question_id,
r.raw_answer_text,
p.brand_mentioned,
p.brand_mention_position,
p.first_recommended,
p.sentiment_label,
p.matched_brand_terms
FROM question_monitor_runs r
LEFT JOIN question_monitor_parse_results p
ON p.tenant_id = r.tenant_id AND p.run_id = r.id
WHERE r.tenant_id = $1
AND 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::text IS NULL OR r.ai_platform_id = $6)
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableString(aiPlatformID))
if err != nil {
return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics")
}
defer rows.Close()
for rows.Next() {
var businessDate time.Time
var platformID string
var questionID int64
var answerText sql.NullString
var brandMentioned sql.NullBool
var brandMentionPosition sql.NullString
var firstRecommended sql.NullBool
var sentimentLabel sql.NullString
var matchedBrandTermsJSON []byte
if scanErr := rows.Scan(
&businessDate,
&platformID,
&questionID,
&answerText,
&brandMentioned,
&brandMentionPosition,
&firstRecommended,
&sentimentLabel,
&matchedBrandTermsJSON,
); scanErr != nil {
return metrics, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring derived metrics")
}
summary := resolveMonitoringAnswerParseSummary(
answerText.String,
brandName,
monitoringStoredParseFields{
BrandMentioned: boolPointer(brandMentioned),
BrandMentionPosition: stringPointer(brandMentionPosition),
FirstRecommended: boolPointer(firstRecommended),
SentimentLabel: stringPointer(sentimentLabel),
MatchedBrandTerms: decodeMonitoringMatchedBrandTerms(matchedBrandTermsJSON),
},
)
dateKey := businessDate.Format("2006-01-02")
dateStats := metrics.ByDate[dateKey]
dateStats.add(summary)
metrics.ByDate[dateKey] = dateStats
platformStatsByDate, ok := metrics.ByDatePlatform[dateKey]
if !ok {
platformStatsByDate = make(map[string]monitoringDerivedRateStats)
metrics.ByDatePlatform[dateKey] = platformStatsByDate
}
platformStats := platformStatsByDate[platformID]
platformStats.add(summary)
platformStatsByDate[platformID] = platformStats
questionStatsByDate, ok := metrics.ByDateQuestion[dateKey]
if !ok {
questionStatsByDate = make(map[int64]monitoringDerivedRateStats)
metrics.ByDateQuestion[dateKey] = questionStatsByDate
}
questionStatsForDate := questionStatsByDate[questionID]
questionStatsForDate.add(summary)
questionStatsByDate[questionID] = questionStatsForDate
questionStats := metrics.ByQuestion[questionID]
questionStats.add(summary)
metrics.ByQuestion[questionID] = questionStats
}
if err := rows.Err(); err != nil {
return metrics, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring derived metrics")
}
return metrics, nil
}
func decodeMonitoringMatchedBrandTerms(raw []byte) []string {
if len(raw) == 0 {
return nil
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
return nil
}
return normalizeMonitoringBrandTermList(items)
}
func (s *MonitoringService) loadOverview(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
if platformID := normalizedOptionalString(aiPlatformID); platformID != "" {
return s.loadOverviewForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
}
overview := MonitoringOverview{
CollectionMode: collectionMode,
ConfidenceLevel: "low",
}
latestDate := endDate
current, found, err := s.loadOverviewSnapshotForDate(ctx, tenantID, brandID, collectionMode, endDate)
if err != nil {
return overview, latestDate, err
}
if !found {
return overview, latestDate, nil
}
applyOverviewSnapshot(&overview, current, derived)
if current.ActualSampleCount <= 0 {
return overview, latestDate, nil
}
latestDate = current.Date
previous, foundPrevious, err := s.loadLatestSampledOverviewSnapshot(ctx, tenantID, brandID, collectionMode, startDate, current.Date.AddDate(0, 0, -1))
if err != nil {
return overview, latestDate, err
}
if !foundPrevious {
return overview, latestDate, nil
}
applyDerivedRatesToOverviewSnapshot(&previous, derived)
overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate)
overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate)
overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate)
overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate)
return overview, latestDate, nil
}
func (s *MonitoringService) loadOverviewForPlatform(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
overview := MonitoringOverview{
CollectionMode: collectionMode,
ConfidenceLevel: "low",
}
latestDate := endDate
current, found, err := s.loadPlatformOverviewSnapshotForDate(ctx, tenantID, brandID, aiPlatformID, collectionMode, endDate)
if err != nil {
return overview, latestDate, err
}
if !found {
return overview, latestDate, nil
}
applyOverviewSnapshot(&overview, current, derived)
if current.ActualSampleCount <= 0 {
return overview, latestDate, nil
}
latestDate = current.Date
previous, foundPrevious, err := s.loadLatestSampledPlatformOverviewSnapshot(
ctx,
tenantID,
brandID,
aiPlatformID,
collectionMode,
startDate,
current.Date.AddDate(0, 0, -1),
)
if err != nil {
return overview, latestDate, err
}
if !foundPrevious {
return overview, latestDate, nil
}
applyDerivedRatesToOverviewSnapshot(&previous, derived)
overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate)
overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate)
overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate)
overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate)
return overview, latestDate, nil
}
type monitoringOverviewSnapshot struct {
Date time.Time
DesiredSampleCount int64
PlannedSampleCount int64
ActualSampleCount int64
CoverageRate sql.NullFloat64
ConfidenceLevel string
TriggerSource sql.NullString
SnapshotUpdatedAt sql.NullTime
MentionRate sql.NullFloat64
Top1MentionRate sql.NullFloat64
FirstRecommendRate sql.NullFloat64
PositiveMentionRate sql.NullFloat64
CitationRate sql.NullFloat64
}
func (s *MonitoringService) loadOverviewSnapshotForDate(ctx context.Context, tenantID, brandID int64, collectionMode string, businessDate time.Time) (monitoringOverviewSnapshot, bool, error) {
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
SELECT
business_date,
desired_sample_count,
planned_sample_count,
actual_sample_count,
coverage_rate::double precision,
confidence_level,
trigger_source,
snapshot_updated_at,
mention_rate::double precision,
top1_mention_rate::double precision,
first_recommend_rate::double precision,
positive_mention_rate::double precision,
citation_rate::double precision
FROM monitoring_brand_daily
WHERE tenant_id = $1
AND brand_id = $2
AND collector_type = $3
AND business_date = $4::date
`, tenantID, brandID, collectionMode, businessDate.Format("2006-01-02")).Scan(
&item.Date,
&item.DesiredSampleCount,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.CoverageRate,
&item.ConfidenceLevel,
&item.TriggerSource,
&item.SnapshotUpdatedAt,
&item.MentionRate,
&item.Top1MentionRate,
&item.FirstRecommendRate,
&item.PositiveMentionRate,
&item.CitationRate,
)
if err != nil {
if err == pgx.ErrNoRows {
return monitoringOverviewSnapshot{}, false, nil
}
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
}
return item, true, nil
}
func (s *MonitoringService) loadLatestSampledOverviewSnapshot(ctx context.Context, tenantID, brandID int64, collectionMode string, startDate, endDate time.Time) (monitoringOverviewSnapshot, bool, error) {
if endDate.Before(startDate) {
return monitoringOverviewSnapshot{}, false, nil
}
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
SELECT
business_date,
desired_sample_count,
planned_sample_count,
actual_sample_count,
coverage_rate::double precision,
confidence_level,
trigger_source,
snapshot_updated_at,
mention_rate::double precision,
top1_mention_rate::double precision,
first_recommend_rate::double precision,
positive_mention_rate::double precision,
citation_rate::double precision
FROM monitoring_brand_daily
WHERE tenant_id = $1
AND brand_id = $2
AND collector_type = $3
AND business_date BETWEEN $4::date AND $5::date
AND actual_sample_count > 0
ORDER BY business_date DESC
LIMIT 1
`, tenantID, brandID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(
&item.Date,
&item.DesiredSampleCount,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.CoverageRate,
&item.ConfidenceLevel,
&item.TriggerSource,
&item.SnapshotUpdatedAt,
&item.MentionRate,
&item.Top1MentionRate,
&item.FirstRecommendRate,
&item.PositiveMentionRate,
&item.CitationRate,
)
if err != nil {
if err == pgx.ErrNoRows {
return monitoringOverviewSnapshot{}, false, nil
}
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
}
return item, true, nil
}
func (s *MonitoringService) loadPlatformOverviewSnapshotForDate(
ctx context.Context,
tenantID, brandID int64,
aiPlatformID string,
collectionMode string,
businessDate time.Time,
) (monitoringOverviewSnapshot, bool, error) {
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
SELECT
business_date,
planned_sample_count,
actual_sample_count,
mention_rate::double precision,
top1_mention_rate::double precision,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND ai_platform_id = $3
AND collector_type = $4
AND business_date = $5::date
`, tenantID, brandID, aiPlatformID, collectionMode, businessDate.Format("2006-01-02")).Scan(
&item.Date,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.MentionRate,
&item.Top1MentionRate,
&item.SnapshotUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return monitoringOverviewSnapshot{}, false, nil
}
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform overview")
}
coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount)
item.DesiredSampleCount = item.PlannedSampleCount
item.CoverageRate = pointerFloat64ToNull(coverageRate)
item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, item.PlannedSampleCount, coverageRate)
return item, true, nil
}
func (s *MonitoringService) loadLatestSampledPlatformOverviewSnapshot(
ctx context.Context,
tenantID, brandID int64,
aiPlatformID string,
collectionMode string,
startDate, endDate time.Time,
) (monitoringOverviewSnapshot, bool, error) {
if endDate.Before(startDate) {
return monitoringOverviewSnapshot{}, false, nil
}
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
SELECT
business_date,
planned_sample_count,
actual_sample_count,
mention_rate::double precision,
top1_mention_rate::double precision,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND ai_platform_id = $3
AND collector_type = $4
AND business_date BETWEEN $5::date AND $6::date
AND actual_sample_count > 0
ORDER BY business_date DESC
LIMIT 1
`, tenantID, brandID, aiPlatformID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(
&item.Date,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.MentionRate,
&item.Top1MentionRate,
&item.SnapshotUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return monitoringOverviewSnapshot{}, false, nil
}
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform overview")
}
coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount)
item.DesiredSampleCount = item.PlannedSampleCount
item.CoverageRate = pointerFloat64ToNull(coverageRate)
item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, item.PlannedSampleCount, coverageRate)
return item, true, nil
}
func applyOverviewSnapshot(overview *MonitoringOverview, snapshot monitoringOverviewSnapshot, derived monitoringDerivedMetrics) {
if overview == nil {
return
}
overview.DesiredSampleCount = snapshot.DesiredSampleCount
overview.PlannedSampleCount = snapshot.PlannedSampleCount
overview.ActualSampleCount = snapshot.ActualSampleCount
overview.CoverageRate = floatPointer(snapshot.CoverageRate)
overview.LastTriggerSource = stringPointer(snapshot.TriggerSource)
if strings.TrimSpace(snapshot.ConfidenceLevel) != "" {
overview.ConfidenceLevel = snapshot.ConfidenceLevel
}
if snapshot.ActualSampleCount <= 0 {
overview.LastSampledAt = nil
overview.MentionRate = nil
overview.Top1MentionRate = nil
overview.FirstRecommendRate = nil
overview.PositiveMentionRate = nil
overview.CitationRate = nil
return
}
applyDerivedRatesToOverviewSnapshot(&snapshot, derived)
overview.LastSampledAt = formatNullTime(snapshot.SnapshotUpdatedAt)
overview.MentionRate = floatPointer(snapshot.MentionRate)
overview.Top1MentionRate = floatPointer(snapshot.Top1MentionRate)
overview.FirstRecommendRate = floatPointer(snapshot.FirstRecommendRate)
overview.PositiveMentionRate = floatPointer(snapshot.PositiveMentionRate)
overview.CitationRate = floatPointer(snapshot.CitationRate)
}
func applyDerivedRatesToOverviewSnapshot(snapshot *monitoringOverviewSnapshot, derived monitoringDerivedMetrics) {
if snapshot == nil {
return
}
dateKey := snapshot.Date.Format("2006-01-02")
if currentPlatformStats, ok := derived.ByDatePlatform[dateKey]; ok {
snapshot.MentionRate = pointerFloat64ToNull(averageDerivedRateByPlatform(currentPlatformStats, func(stats monitoringDerivedRateStats) *float64 {
return stats.mentionRate()
}))
}
if stats, ok := derived.ByDate[dateKey]; ok && stats.hasSamples() {
snapshot.Top1MentionRate = pointerFloat64ToNull(stats.top1MentionRate())
snapshot.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate())
snapshot.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate())
}
}
func (s *MonitoringService) loadTimeBuckets(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) ([]MonitoringTimeBucket, error) {
if platformID := normalizedOptionalString(aiPlatformID); platformID != "" {
return s.loadTimeBucketsForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT
business_date,
sample_status,
mention_rate::double precision,
top1_mention_rate::double precision,
first_recommend_rate::double precision,
positive_mention_rate::double precision,
citation_rate::double precision,
planned_sample_count,
actual_sample_count,
coverage_rate::double precision,
trigger_source,
snapshot_updated_at
FROM monitoring_brand_daily
WHERE tenant_id = $1
AND brand_id = $2
AND collector_type = $3
AND business_date BETWEEN $4::date AND $5::date
ORDER BY business_date ASC
`, tenantID, brandID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring time buckets")
}
defer rows.Close()
type bucketRow struct {
Date time.Time
SampleStatus string
MentionRate sql.NullFloat64
Top1MentionRate sql.NullFloat64
FirstRecommendRate sql.NullFloat64
PositiveMentionRate sql.NullFloat64
CitationRate sql.NullFloat64
PlannedSampleCount int64
ActualSampleCount int64
CoverageRate sql.NullFloat64
TriggerSource sql.NullString
SnapshotUpdatedAt sql.NullTime
}
bucketMap := make(map[string]bucketRow)
for rows.Next() {
var item bucketRow
if scanErr := rows.Scan(
&item.Date,
&item.SampleStatus,
&item.MentionRate,
&item.Top1MentionRate,
&item.FirstRecommendRate,
&item.PositiveMentionRate,
&item.CitationRate,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.CoverageRate,
&item.TriggerSource,
&item.SnapshotUpdatedAt,
); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring time buckets")
}
bucketMap[item.Date.Format("2006-01-02")] = item
}
buckets := make([]MonitoringTimeBucket, 0, int(endDate.Sub(startDate).Hours()/24)+1)
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
key := cursor.Format("2006-01-02")
if item, ok := bucketMap[key]; ok {
if platformStats, ok := derived.ByDatePlatform[key]; ok {
item.MentionRate = pointerFloat64ToNull(averageDerivedRateByPlatform(platformStats, func(stats monitoringDerivedRateStats) *float64 {
return stats.mentionRate()
}))
}
if stats, ok := derived.ByDate[key]; ok && stats.hasSamples() {
item.Top1MentionRate = pointerFloat64ToNull(stats.top1MentionRate())
item.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate())
item.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate())
}
buckets = append(buckets, MonitoringTimeBucket{
Date: key,
SampleStatus: item.SampleStatus,
MetricValue: floatPointer(item.MentionRate),
MentionRate: floatPointer(item.MentionRate),
Top1MentionRate: floatPointer(item.Top1MentionRate),
FirstRecommendRate: floatPointer(item.FirstRecommendRate),
PositiveMentionRate: floatPointer(item.PositiveMentionRate),
CitationRate: floatPointer(item.CitationRate),
PlannedSampleCount: item.PlannedSampleCount,
ActualSampleCount: item.ActualSampleCount,
CoverageRate: floatPointer(item.CoverageRate),
TriggerSource: stringPointer(item.TriggerSource),
SnapshotUpdatedAt: formatNullTime(item.SnapshotUpdatedAt),
})
continue
}
buckets = append(buckets, MonitoringTimeBucket{
Date: key,
SampleStatus: "unsampled",
})
}
return buckets, nil
}
func (s *MonitoringService) loadTimeBucketsForPlatform(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID string,
derived monitoringDerivedMetrics,
) ([]MonitoringTimeBucket, error) {
rows, err := s.monitoringPool.Query(ctx, `
SELECT
business_date,
platform_sample_status,
mention_rate::double precision,
top1_mention_rate::double precision,
planned_sample_count,
actual_sample_count,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND ai_platform_id = $3
AND collector_type = $4
AND business_date BETWEEN $5::date AND $6::date
ORDER BY business_date ASC
`, tenantID, brandID, aiPlatformID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform time buckets")
}
defer rows.Close()
type bucketRow struct {
Date time.Time
SampleStatus string
MentionRate sql.NullFloat64
Top1MentionRate sql.NullFloat64
FirstRecommendRate sql.NullFloat64
PositiveMentionRate sql.NullFloat64
PlannedSampleCount int64
ActualSampleCount int64
SnapshotUpdatedAt sql.NullTime
}
bucketMap := make(map[string]bucketRow)
for rows.Next() {
var item bucketRow
if scanErr := rows.Scan(
&item.Date,
&item.SampleStatus,
&item.MentionRate,
&item.Top1MentionRate,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.SnapshotUpdatedAt,
); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring platform time buckets")
}
bucketMap[item.Date.Format("2006-01-02")] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring platform time buckets")
}
buckets := make([]MonitoringTimeBucket, 0, int(endDate.Sub(startDate).Hours()/24)+1)
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
key := cursor.Format("2006-01-02")
if item, ok := bucketMap[key]; ok {
if platformStatsByDate, ok := derived.ByDatePlatform[key]; ok {
if platformStats, ok := platformStatsByDate[aiPlatformID]; ok && platformStats.hasSamples() {
item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate())
item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate())
}
}
if stats, ok := derived.ByDate[key]; ok && stats.hasSamples() {
item.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate())
item.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate())
}
coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount)
buckets = append(buckets, MonitoringTimeBucket{
Date: key,
SampleStatus: item.SampleStatus,
MetricValue: floatPointer(item.MentionRate),
MentionRate: floatPointer(item.MentionRate),
Top1MentionRate: floatPointer(item.Top1MentionRate),
FirstRecommendRate: floatPointer(item.FirstRecommendRate),
PositiveMentionRate: floatPointer(item.PositiveMentionRate),
CitationRate: nil,
PlannedSampleCount: item.PlannedSampleCount,
ActualSampleCount: item.ActualSampleCount,
CoverageRate: coverageRate,
TriggerSource: nil,
SnapshotUpdatedAt: formatNullTime(item.SnapshotUpdatedAt),
})
continue
}
buckets = append(buckets, MonitoringTimeBucket{
Date: key,
SampleStatus: "unsampled",
})
}
return buckets, nil
}
func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64, clientID *uuid.UUID, businessDate time.Time) (map[string]monitoringAccessState, error) {
states := map[string]monitoringAccessState{}
if clientID == nil {
return states, nil
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT ai_platform_id, access_status
FROM monitoring_platform_access_snapshots
WHERE tenant_id = $1
AND client_id = $2
AND business_date = $3::date
`, tenantID, *clientID, businessDate.Format("2006-01-02"))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load platform access states")
}
defer rows.Close()
for rows.Next() {
var platformID string
var accessStatus string
if scanErr := rows.Scan(&platformID, &accessStatus); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform access states")
}
states[platformID] = monitoringAccessState{AccessStatus: accessStatus}
}
return states, nil
}
func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
rows, err := s.monitoringPool.Query(ctx, `
SELECT
ai_platform_id,
mention_rate::double precision,
top1_mention_rate::double precision,
platform_sample_status,
actual_sample_count,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND collector_type = $3
AND business_date = $4::date
`, tenantID, brandID, collectionMode, businessDate.Format("2006-01-02"))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load platform breakdown")
}
defer rows.Close()
type row struct {
MentionRate sql.NullFloat64
Top1MentionRate sql.NullFloat64
PlatformSampleStatus string
ActualSampleCount int64
LastSampledAt sql.NullTime
}
items := make(map[string]row)
for rows.Next() {
var platformID string
var item row
if scanErr := rows.Scan(
&platformID,
&item.MentionRate,
&item.Top1MentionRate,
&item.PlatformSampleStatus,
&item.ActualSampleCount,
&item.LastSampledAt,
); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform breakdown")
}
items[platformID] = item
}
dateKey := businessDate.Format("2006-01-02")
breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
for _, platform := range platforms {
if item, ok := items[platform.ID]; ok {
if platformStats, ok := derived.ByDatePlatform[dateKey][platform.ID]; ok && platformStats.hasSamples() {
item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate())
item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate())
}
breakdown = append(breakdown, MonitoringPlatformDaily{
AIPlatformID: platform.ID,
PlatformName: platform.Name,
MentionRate: floatPointer(item.MentionRate),
Top1MentionRate: floatPointer(item.Top1MentionRate),
PlatformSampleStatus: derivePlatformSampleStatus(item.PlatformSampleStatus, accessStates[platform.ID]),
ActualSampleCount: item.ActualSampleCount,
LastSampledAt: formatNullTime(item.LastSampledAt),
})
continue
}
breakdown = append(breakdown, MonitoringPlatformDaily{
AIPlatformID: platform.ID,
PlatformName: platform.Name,
PlatformSampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]),
})
}
return breakdown, nil
}
func (s *MonitoringService) loadHotQuestions(
ctx context.Context,
tenantID, brandID int64,
questions []monitoringConfiguredQuestion,
businessDate time.Time,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) ([]MonitoringHotQuestion, error) {
if len(questions) == 0 {
return []MonitoringHotQuestion{}, nil
}
questionIDs := configuredQuestionIDs(questions)
selectedDate := businessDate.Format("2006-01-02")
rows, err := s.monitoringPool.Query(ctx, `
WITH stats AS (
SELECT
r.question_id,
AVG(CASE WHEN p.brand_mentioned IS TRUE THEN 1 ELSE 0 END)::double precision AS mention_rate,
MAX(r.completed_at) AS last_sampled_at
FROM question_monitor_runs r
LEFT JOIN question_monitor_parse_results p
ON p.tenant_id = r.tenant_id AND p.run_id = r.id
WHERE r.tenant_id = $1
AND r.brand_id = $2
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date = $4::date
AND r.question_id = ANY($5)
AND ($6::text IS NULL OR r.ai_platform_id = $6)
GROUP BY r.question_id
),
latest_hash AS (
SELECT DISTINCT ON (r.question_id)
r.question_id,
r.question_hash
FROM question_monitor_runs r
WHERE r.tenant_id = $1
AND r.brand_id = $2
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date = $4::date
AND r.question_id = ANY($5)
AND ($6::text IS NULL OR r.ai_platform_id = $6)
ORDER BY r.question_id, r.business_date DESC, COALESCE(r.completed_at, r.updated_at, r.created_at) DESC NULLS LAST, r.id DESC
)
SELECT
s.question_id,
h.question_hash,
s.mention_rate,
s.last_sampled_at
FROM stats s
LEFT JOIN latest_hash h ON h.question_id = s.question_id
`, tenantID, brandID, monitoringCollectorType, selectedDate, questionIDs, nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load hot questions")
}
defer rows.Close()
type questionStat struct {
QuestionHash []byte
MentionRate sql.NullFloat64
LastSampledAt sql.NullTime
}
stats := make(map[int64]questionStat, len(questions))
for rows.Next() {
var questionID int64
var item questionStat
if scanErr := rows.Scan(&questionID, &item.QuestionHash, &item.MentionRate, &item.LastSampledAt); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse hot questions")
}
stats[questionID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate hot questions")
}
items := make([]MonitoringHotQuestion, 0, len(questions))
for _, question := range questions {
item := MonitoringHotQuestion{
QuestionID: question.ID,
QuestionHash: encodeQuestionHash(question.QuestionHash),
QuestionText: question.QuestionText,
}
if stat, ok := stats[question.ID]; ok {
if len(stat.QuestionHash) > 0 {
item.QuestionHash = encodeQuestionHash(stat.QuestionHash)
}
item.MentionRate = floatPointer(stat.MentionRate)
item.LastSampledAt = formatNullTime(stat.LastSampledAt)
}
if questionStatsByDate, ok := derived.ByDateQuestion[selectedDate]; ok {
if derivedStats, ok := questionStatsByDate[question.ID]; ok && derivedStats.hasSamples() {
item.MentionRate = derivedStats.mentionRate()
}
}
items = append(items, item)
}
return items, nil
}
func (s *MonitoringService) loadCitationRanking(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
accessStates map[string]monitoringAccessState,
aiPlatformID *string,
) ([]MonitoringCitationRanking, error) {
if questionIDs != nil && len(questionIDs) == 0 {
return []MonitoringCitationRanking{}, nil
}
rows, err := s.monitoringPool.Query(ctx, `
WITH 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 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 = $7)
GROUP BY r.ai_platform_id
),
citation_counts AS (
SELECT
r.ai_platform_id,
COUNT(DISTINCT cf.run_id) FILTER (WHERE cf.article_id IS NOT NULL) AS cited_answer_count,
COUNT(DISTINCT cf.article_id) FILTER (WHERE cf.article_id IS NOT NULL) AS cited_article_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.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 = $7)
GROUP BY r.ai_platform_id
)
SELECT
rc.ai_platform_id,
COALESCE(cc.cited_answer_count, 0) AS cited_answer_count,
COALESCE(cc.cited_article_count, 0) AS cited_article_count,
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
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
}
defer rows.Close()
items := make([]MonitoringCitationRanking, 0)
for rows.Next() {
var platformID string
var citedAnswerCount int64
var citedArticleCount int64
var citationRate sql.NullFloat64
if scanErr := rows.Scan(&platformID, &citedAnswerCount, &citedArticleCount, &citationRate); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
}
items = append(items, MonitoringCitationRanking{
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
SampleStatus: derivePlatformSampleStatus("", accessStates[platformID]),
CitedAnswerCount: citedAnswerCount,
CitedArticleCount: citedArticleCount,
CitationRate: floatPointer(citationRate),
})
}
return items, nil
}
func (s *MonitoringService) loadCitedArticles(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
aiPlatformID *string,
) ([]MonitoringCitedArticle, error) {
if questionIDs != nil && len(questionIDs) == 0 {
return []MonitoringCitedArticle{}, nil
}
var totalSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*)
FROM question_monitor_runs r
WHERE r.tenant_id = $1
AND 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 = $7)
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID)).Scan(&totalSampleCount); err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to count cited article denominator")
}
rows, err := s.monitoringPool.Query(ctx, `
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.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 = $7)
GROUP BY cf.article_id
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), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
}
defer rows.Close()
items := make([]MonitoringCitedArticle, 0)
for rows.Next() {
var articleID int64
var title string
var publishPlatform string
var citationCount int64
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
}
items = append(items, MonitoringCitedArticle{
ArticleID: articleID,
ArticleTitle: title,
PublishPlatform: publishPlatform,
CitationCount: citationCount,
CitationRate: divideAsPointer(citationCount, totalSampleCount),
})
}
return items, nil
}
func (s *MonitoringService) loadQuestionText(ctx context.Context, tenantID, brandID, questionID int64) (string, error) {
var text string
if err := s.businessPool.QueryRow(ctx, `
SELECT question_text
FROM brand_questions
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, questionID, tenantID, brandID).Scan(&text); err != nil {
if err == pgx.ErrNoRows {
return "", response.ErrNotFound(40441, "question_not_found", "question not found")
}
return "", response.ErrInternal(50041, "query_failed", "failed to load question")
}
return text, nil
}
func (s *MonitoringService) loadLatestQuestionHash(ctx context.Context, tenantID, brandID, questionID int64, fromDate, toDate time.Time) ([]byte, error) {
var hashBytes []byte
if err := s.monitoringPool.QueryRow(ctx, `
SELECT question_hash
FROM (
SELECT
question_hash,
business_date,
COALESCE(completed_at, updated_at, created_at) AS event_at
FROM question_monitor_runs
WHERE tenant_id = $1
AND brand_id = $2
AND question_id = $3
AND collector_type = $4
AND business_date BETWEEN $5::date AND $6::date
UNION ALL
SELECT
question_hash,
business_date,
COALESCE(completed_at, callback_received_at, leased_at, planned_at, updated_at, created_at) AS event_at
FROM monitoring_collect_tasks
WHERE tenant_id = $1
AND brand_id = $2
AND question_id = $3
AND collector_type = $4
AND business_date BETWEEN $5::date AND $6::date
) AS hashes
ORDER BY business_date DESC, event_at DESC NULLS LAST
LIMIT 1
`, tenantID, brandID, questionID, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02")).Scan(&hashBytes); err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, response.ErrInternal(50041, "query_failed", "failed to resolve question hash")
}
return hashBytes, nil
}
func (s *MonitoringService) loadLatestPlatformRuns(ctx context.Context, tenantID, brandID int64, brandName string, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) (map[string]monitoringLatestPlatformRun, error) {
rows, err := s.monitoringPool.Query(ctx, `
SELECT DISTINCT ON (r.ai_platform_id)
r.ai_platform_id,
r.id,
r.completed_at,
r.provider_model,
r.raw_answer_text,
p.brand_mentioned,
p.brand_mention_position
FROM question_monitor_runs r
LEFT JOIN question_monitor_parse_results p
ON p.tenant_id = r.tenant_id AND p.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 = $8)
ORDER BY r.ai_platform_id ASC, r.completed_at DESC NULLS LAST, r.id DESC
`, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load question runs")
}
defer rows.Close()
items := make(map[string]monitoringLatestPlatformRun)
for rows.Next() {
var item monitoringLatestPlatformRun
if scanErr := rows.Scan(
&item.PlatformID,
&item.RunID,
&item.CompletedAt,
&item.ProviderModel,
&item.AnswerText,
&item.BrandMentioned,
&item.BrandMentionPosition,
); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse latest question runs")
}
summary := resolveMonitoringAnswerParseSummary(
item.AnswerText.String,
brandName,
monitoringStoredParseFields{
BrandMentioned: boolPointer(item.BrandMentioned),
BrandMentionPosition: stringPointer(item.BrandMentionPosition),
},
)
item.BrandMentioned = sql.NullBool{Bool: summary.BrandMentioned, Valid: true}
item.BrandMentionPosition = pointerStringToNull(optionalString(summary.BrandMentionPosition))
items[item.PlatformID] = item
}
return items, nil
}
func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID int64, runIDs []int64) (map[int64][]MonitoringQuestionDetailCitation, error) {
result := make(map[int64][]MonitoringQuestionDetailCitation)
if len(runIDs) == 0 {
return result, nil
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT
cf.run_id,
cf.cited_url,
cf.cited_title,
COALESCE(tm.site_name, gm.site_name, cf.site_key, cf.registrable_domain) AS site_name,
COALESCE(tm.site_key, gm.site_key, cf.site_key) AS site_key,
cf.article_id,
alias.article_title_snapshot AS article_title,
cf.resolution_status,
cf.resolution_confidence
FROM monitoring_citation_facts cf
LEFT JOIN LATERAL (
SELECT site_key, site_name
FROM site_domain_mappings
WHERE tenant_id = cf.tenant_id
AND is_active = TRUE
AND (
registrable_domain = cf.registrable_domain
OR registrable_domain = cf.site_key
OR cf.host = registrable_domain
OR cf.host LIKE '%.' || registrable_domain
)
ORDER BY LENGTH(registrable_domain) DESC, id DESC
LIMIT 1
) tm ON TRUE
LEFT JOIN LATERAL (
SELECT site_key, site_name
FROM site_domain_mappings
WHERE tenant_id IS NULL
AND is_active = TRUE
AND (
registrable_domain = cf.registrable_domain
OR registrable_domain = cf.site_key
OR cf.host = registrable_domain
OR cf.host LIKE '%.' || registrable_domain
)
ORDER BY LENGTH(registrable_domain) DESC, id DESC
LIMIT 1
) gm ON tm.site_key IS NULL
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.run_id = ANY($2)
ORDER BY cf.run_id ASC, cf.id ASC
`, tenantID, runIDs)
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citations")
}
defer rows.Close()
for rows.Next() {
var runID int64
var citedURL string
var citedTitle sql.NullString
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 {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
}
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
CitedURL: citedURL,
CitedTitle: nullableStringValue(citedTitle),
SiteName: siteName,
SiteKey: siteKey,
FaviconURL: faviconURL(siteKey),
ArticleID: nullableInt64Value(articleID),
ArticleTitle: nullableStringValue(articleTitle),
ResolutionStatus: resolutionStatus,
ResolutionConfidence: resolutionConfidence,
})
}
return result, nil
}
func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionCitationStats, error) {
rows, err := s.monitoringPool.Query(ctx, `
WITH grouped AS (
SELECT
r.ai_platform_id,
COALESCE(tm.site_name, gm.site_name, cf.site_key, cf.registrable_domain) AS site_name,
COALESCE(tm.site_key, gm.site_key, cf.site_key) AS site_key,
COALESCE(tm.registrable_domain, gm.registrable_domain, cf.site_key, cf.registrable_domain) AS site_domain,
COUNT(cf.id) AS citation_count,
COUNT(*) FILTER (WHERE cf.article_id IS NOT NULL) AS content_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
LEFT JOIN LATERAL (
SELECT registrable_domain, site_key, site_name
FROM site_domain_mappings
WHERE tenant_id = cf.tenant_id
AND is_active = TRUE
AND (
registrable_domain = cf.registrable_domain
OR registrable_domain = cf.site_key
OR cf.host = registrable_domain
OR cf.host LIKE '%.' || registrable_domain
)
ORDER BY LENGTH(registrable_domain) DESC, id DESC
LIMIT 1
) tm ON TRUE
LEFT JOIN LATERAL (
SELECT registrable_domain, site_key, site_name
FROM site_domain_mappings
WHERE tenant_id IS NULL
AND is_active = TRUE
AND (
registrable_domain = cf.registrable_domain
OR registrable_domain = cf.site_key
OR cf.host = registrable_domain
OR cf.host LIKE '%.' || registrable_domain
)
ORDER BY LENGTH(registrable_domain) DESC, id DESC
LIMIT 1
) gm ON tm.site_key IS NULL
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 = $8)
GROUP BY
r.ai_platform_id,
COALESCE(tm.site_name, gm.site_name, cf.site_key, cf.registrable_domain),
COALESCE(tm.site_key, gm.site_key, cf.site_key),
COALESCE(tm.registrable_domain, gm.registrable_domain, cf.site_key, cf.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
`, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
}
defer rows.Close()
items := make([]MonitoringQuestionCitationStats, 0)
for rows.Next() {
var platformID string
var siteName string
var siteKey string
var siteDomain string
var citationCount int64
var citationRate sql.NullFloat64
var contentCitationCount int64
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, &citationRate, &contentCitationCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
}
items = append(items, MonitoringQuestionCitationStats{
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
SiteName: siteName,
SiteKey: siteKey,
SiteDomain: siteDomain,
CitationCount: citationCount,
CitationRate: floatPointer(citationRate),
ContentCitationCount: contentCitationCount,
})
}
return items, nil
}
func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionContentCitation, error) {
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.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 = $8)
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
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.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 ($8::text IS NULL OR r.ai_platform_id = $8)
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"), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
}
defer rows.Close()
items := make([]MonitoringQuestionContentCitation, 0)
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 {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
}
items = append(items, MonitoringQuestionContentCitation{
ArticleID: articleID,
ArticleTitle: articleTitle,
PublishPlatform: publishPlatform,
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
CitationCount: citationCount,
CitationRate: divideAsPointer(citationCount, totalCitationCount),
})
}
return items, nil
}
func (s *MonitoringService) ensureClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) error {
online, err := s.isClientOnline(ctx, tenantID, workspaceID, clientID)
if err != nil {
return err
}
if !online {
return response.ErrConflict(40941, "desktop_client_offline", "primary monitoring desktop client is offline")
}
return nil
}
func (s *MonitoringService) isClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) (bool, error) {
var lastSeenAt sql.NullTime
err := s.businessPool.QueryRow(ctx, `
SELECT last_seen_at
FROM desktop_clients
WHERE id = $1
AND tenant_id = $2
AND workspace_id = $3
AND revoked_at IS NULL
`, clientID, tenantID, workspaceID).Scan(&lastSeenAt)
if err != nil {
if err == pgx.ErrNoRows {
return false, nil
}
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client")
}
if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > monitoringOnlineDuration {
return false, nil
}
return true, nil
}
func parseDetailDateRange(dateFrom, dateTo string) (time.Time, time.Time, error) {
return parseDetailDateRangeAt(dateFrom, dateTo, time.Now(), monitoringBusinessLocation())
}
func parseDetailDateRangeAt(dateFrom, dateTo string, now time.Time, loc *time.Location) (time.Time, time.Time, error) {
if strings.TrimSpace(dateFrom) == "" && strings.TrimSpace(dateTo) == "" {
today := monitoringBusinessDayAt(now, loc)
return today, today, nil
}
if strings.TrimSpace(dateFrom) == "" || strings.TrimSpace(dateTo) == "" {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_from and date_to must be provided together")
}
fromDate, err := parseMonitoringBusinessDateInLocation(dateFrom, loc)
if err != nil {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_from must use YYYY-MM-DD")
}
toDate, err := parseMonitoringBusinessDateInLocation(dateTo, loc)
if err != nil {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_to must use YYYY-MM-DD")
}
if fromDate.After(toDate) {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_date_range", "date_from must not be later than date_to")
}
return fromDate, toDate, nil
}
func resolveDashboardDateWindow(days int, businessDate string) (time.Time, time.Time, error) {
return resolveDashboardDateWindowAt(days, businessDate, time.Now(), monitoringBusinessLocation())
}
func resolveDashboardDateWindowAt(days int, businessDate string, now time.Time, loc *time.Location) (time.Time, time.Time, error) {
today := monitoringBusinessDayAt(now, loc)
earliestAllowed := today.AddDate(0, 0, -(maxTrackingDays - 1))
endDate := today
if strings.TrimSpace(businessDate) != "" {
parsedDate, err := parseMonitoringBusinessDateInLocation(businessDate, loc)
if err != nil {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_business_date", "business_date must use YYYY-MM-DD")
}
if parsedDate.After(today) {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_business_date", "business_date must not be later than today")
}
if parsedDate.Before(earliestAllowed) {
return time.Time{}, time.Time{}, response.ErrBadRequest(40031, "invalid_business_date", "business_date must be within the latest 30 days")
}
endDate = parsedDate
}
if days <= 0 {
days = maxTrackingDays
}
days = normalizeTrackingDays(days)
startDate := endDate.AddDate(0, 0, -(days - 1))
if startDate.Before(earliestAllowed) {
startDate = earliestAllowed
}
return startDate, endDate, nil
}
func trackingDateWindow(days int) (time.Time, time.Time) {
endDate := monitoringBusinessToday()
startDate := endDate.AddDate(0, 0, -(days - 1))
return startDate, endDate
}
func monitoringBusinessToday() time.Time {
return monitoringBusinessDayAt(time.Now(), monitoringBusinessLocation())
}
func monitoringBusinessDayAt(now time.Time, loc *time.Location) time.Time {
if loc == nil {
loc = time.Local
}
localNow := now.In(loc)
return time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
}
func parseMonitoringBusinessDateInLocation(value string, loc *time.Location) (time.Time, error) {
if loc == nil {
loc = time.Local
}
return time.ParseInLocation("2006-01-02", strings.TrimSpace(value), loc)
}
func monitoringRecentMetricsStart(startDate, endDate time.Time) time.Time {
recentStart := endDate.AddDate(0, 0, -1)
if startDate.After(recentStart) {
return startDate
}
return recentStart
}
func normalizeTrackingDays(days int) int {
if days <= 0 {
return defaultTrackingDays
}
if days > maxTrackingDays {
return maxTrackingDays
}
return days
}
func resolveMonitoringPlatforms(enabled []string) []monitoringPlatformMetadata {
if len(enabled) == 0 {
return defaultMonitoringPlatforms
}
platforms := make([]monitoringPlatformMetadata, 0, len(enabled))
seen := make(map[string]struct{}, len(enabled))
for _, item := range enabled {
id := strings.TrimSpace(item)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
platforms = append(platforms, monitoringPlatformMetadata{
ID: id,
Name: platformDisplayName(id),
})
}
if len(platforms) == 0 {
return defaultMonitoringPlatforms
}
return platforms
}
func filterMonitoringPlatforms(platforms []monitoringPlatformMetadata, aiPlatformID *string) []monitoringPlatformMetadata {
platformID := normalizedOptionalString(aiPlatformID)
if platformID == "" {
return platforms
}
filtered := make([]monitoringPlatformMetadata, 0, 1)
for _, platform := range platforms {
if platform.ID == platformID {
filtered = append(filtered, platform)
return filtered
}
}
return []monitoringPlatformMetadata{
{
ID: platformID,
Name: platformDisplayName(platformID),
},
}
}
func platformDisplayName(platformID string) string {
if name, ok := monitoringPlatformNames[platformID]; ok {
return name
}
if platformID == "" {
return "未知平台"
}
return strings.ToUpper(platformID)
}
func normalizedOptionalString(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func derivePlatformSampleStatus(rawStatus string, accessState monitoringAccessState) string {
switch strings.TrimSpace(rawStatus) {
case "sampled":
return "sampled"
case "pending":
return "pending"
case "not_logged_in":
return "not_logged_in"
case "unavailable":
return "unavailable"
}
switch accessState.AccessStatus {
case "not_logged_in":
return "not_logged_in"
case "unavailable":
return "unavailable"
case "accessible":
return "pending"
default:
return "pending"
}
}
func divideAsPointer(numerator, denominator int64) *float64 {
if denominator <= 0 {
return nil
}
value := float64(numerator) / float64(denominator)
return &value
}
func pointerFloat64ToNull(value *float64) sql.NullFloat64 {
if value == nil {
return sql.NullFloat64{}
}
return sql.NullFloat64{Float64: *value, Valid: true}
}
func pointerStringToNull(value *string) sql.NullString {
if value == nil {
return sql.NullString{}
}
return sql.NullString{String: *value, Valid: true}
}
func floatPointer(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
v := value.Float64
return &v
}
func formatNullTime(value sql.NullTime) *string {
if !value.Valid {
return nil
}
text := value.Time.UTC().Format(time.RFC3339)
return &text
}
func stringPointer(value sql.NullString) *string {
if !value.Valid {
return nil
}
v := value.String
return &v
}
func nullableStringValue(value sql.NullString) *string {
if !value.Valid {
return nil
}
v := value.String
return &v
}
func boolPointer(value sql.NullBool) *bool {
if !value.Valid {
return nil
}
v := value.Bool
return &v
}
func nullableInt64Value(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
}
v := value.Int64
return &v
}
func nullableInt64(value *int64) interface{} {
if value == nil {
return nil
}
return *value
}
func nullableInt64Array(values []int64) interface{} {
if values == nil {
return nil
}
return values
}
func nullableString(value *string) interface{} {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return trimmed
}
func encodeQuestionHash(hashBytes []byte) string {
if len(hashBytes) == 0 {
return ""
}
return "v1:" + hex.EncodeToString(hashBytes)
}
func decodeQuestionHash(value string) ([]byte, error) {
trimmed := strings.TrimSpace(value)
trimmed = strings.TrimPrefix(trimmed, "v1:")
if trimmed == "" {
return nil, fmt.Errorf("empty hash")
}
return hex.DecodeString(trimmed)
}
func seededQuestionHash(text string) []byte {
digest := md5.Sum([]byte(strings.TrimSpace(strings.ToLower(text))))
return digest[:]
}
func faviconURL(siteKey string) string {
siteKey = strings.TrimSpace(siteKey)
if siteKey == "" {
return ""
}
return fmt.Sprintf("https://%s/favicon.ico", siteKey)
}