package main import ( "context" "crypto/sha256" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/tenant/prompts" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type seedState struct { TenantID int64 UserID int64 WorkspaceID int64 TestTenantID int64 TestUserID int64 PlanID int64 BrandID int64 KeywordPrimaryID int64 KeywordSecondaryID int64 QuestionPrimaryID int64 QuestionSecondaryID int64 QuestionTertiaryID int64 ArticleID int64 DesktopClientID uuid.UUID } type monitoringRunSeed struct { BusinessDate time.Time CompletedAt time.Time QuestionID int64 QuestionText string QuestionHash []byte AIPlatformID string ProviderModel string AnswerText string BrandMentioned bool MentionPosition string FirstRecommended bool SentimentLabel string MatchedBrandTerms []string Citations []citationSeed } type citationSeed struct { URL string Title string NormalizedURL string Host string RegistrableDomain string SiteKey string ArticleID *int64 PublishRecordID *int64 ResolutionStatus string ResolutionConfidence string } type dailySeed struct { Date time.Time SampleStatus string DesiredSampleCount int PlannedSampleCount int ActualSampleCount int CoverageRate float64 ConfidenceLevel string TriggerSource string MentionedCount int Top1MentionedCount int FirstRecommendedCount int PositiveMentionedCount int CitedAnswerCount int MentionRate float64 Top1MentionRate float64 FirstRecommendRate float64 PositiveMentionRate float64 CitationRate float64 } func main() { configPath := "configs/config.yaml" if p := os.Getenv("CONFIG_PATH"); p != "" { configPath = p } cfg, err := config.Load(configPath) if err != nil { log.Fatalf("load config: %v", err) } prompts.SetConfigFile(filepath.Join(filepath.Dir(configPath), "prompts.yml")) ctx := context.Background() businessPool, err := pgxpool.New(ctx, cfg.Database.DSN()) if err != nil { log.Fatalf("connect business db: %v", err) } defer businessPool.Close() monitoringPool, err := pgxpool.New(ctx, cfg.MonitoringDatabase.DSN()) if err != nil { log.Fatalf("connect monitoring db: %v", err) } defer monitoringPool.Close() if err := businessPool.Ping(ctx); err != nil { log.Fatalf("ping business db: %v", err) } if err := monitoringPool.Ping(ctx); err != nil { log.Fatalf("ping monitoring db: %v", err) } state, err := seedBusinessData(ctx, businessPool, cfg.Membership) if err != nil { log.Fatalf("seed business data: %v", err) } if err := seedMonitoringData(ctx, monitoringPool, state); err != nil { log.Fatalf("seed monitoring data: %v", err) } fmt.Println("Seed data inserted successfully.") fmt.Printf(" Tenant ID: %d\n", state.TenantID) fmt.Printf(" User ID: %d (admin@geo.local / Admin@123)\n", state.UserID) fmt.Printf(" Test Tenant ID: %d\n", state.TestTenantID) fmt.Printf(" Test User ID: %d (test@geo.local / Test@123)\n", state.TestUserID) fmt.Printf(" Plan ID: %d (free)\n", state.PlanID) fmt.Printf(" Brand ID: %d (轻氧口腔)\n", state.BrandID) fmt.Printf(" Desktop Client ID: %s\n", state.DesktopClientID.String()) fmt.Println(" Templates: 4 platform presets") fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts") } func seedBusinessData(ctx context.Context, pool *pgxpool.Pool, membershipCfg config.MembershipConfig) (*seedState, error) { hash, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } tx, err := pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() state := &seedState{} if err := repository.NewTenantPlanRepository(tx).UpsertConfiguredPlans(ctx, membershipCfg); err != nil { return nil, fmt.Errorf("sync membership plans: %w", err) } defaultPlan, ok := membershipCfg.FindPlan(membershipCfg.DefaultPlanCode) if !ok { defaultPlan, _ = membershipCfg.FindPlan("free") } if defaultPlan.Code == "" && len(membershipCfg.Plans) > 0 { defaultPlan = membershipCfg.Plans[0] } state.TenantID, err = ensureTenant(ctx, tx) if err != nil { return nil, err } state.UserID, err = ensureUser(ctx, tx, string(hash)) if err != nil { return nil, err } if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil { return nil, err } state.WorkspaceID, err = ensureDefaultWorkspace(ctx, tx, state.TenantID) if err != nil { return nil, err } if err := ensurePrimaryWorkspaceMembership(ctx, tx, state.WorkspaceID, state.TenantID, state.UserID); err != nil { return nil, err } state.PlanID, err = ensurePlan(ctx, tx, defaultPlan.Code) if err != nil { return nil, err } if err := ensureSubscription(ctx, tx, state.TenantID, state.PlanID, defaultPlan.SubscriptionDuration); err != nil { return nil, err } if err := ensureQuotaLedger(ctx, tx, state.TenantID, defaultPlan.ArticleGeneration); err != nil { return nil, err } if err := ensureTemplates(ctx, tx); err != nil { return nil, err } state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID, defaultPlan) if err != nil { return nil, err } state.BrandID, err = ensureBrand(ctx, tx, state.TenantID) if err != nil { return nil, err } state.KeywordPrimaryID, err = ensureKeyword(ctx, tx, state.TenantID, state.BrandID, "北京口腔医院") if err != nil { return nil, err } state.KeywordSecondaryID, err = ensureKeyword(ctx, tx, state.TenantID, state.BrandID, "牙齿种植") if err != nil { return nil, err } state.QuestionPrimaryID, err = ensureQuestion(ctx, tx, state.TenantID, state.BrandID, state.KeywordPrimaryID, "北京口腔医院推荐?") if err != nil { return nil, err } state.QuestionSecondaryID, err = ensureQuestion(ctx, tx, state.TenantID, state.BrandID, state.KeywordSecondaryID, "北京种植牙哪家靠谱?") if err != nil { return nil, err } state.QuestionTertiaryID, err = ensureQuestion(ctx, tx, state.TenantID, state.BrandID, state.KeywordPrimaryID, "北京牙齿矫正医院怎么选?") if err != nil { return nil, err } if err := ensureCompetitor(ctx, tx, state.TenantID, state.BrandID); err != nil { return nil, err } state.ArticleID, err = ensureArticle(ctx, tx, state.TenantID) if err != nil { return nil, err } state.DesktopClientID, err = ensureDesktopClient(ctx, tx, state.TenantID, state.WorkspaceID, state.UserID) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit business seed: %w", err) } return state, nil } func seedMonitoringData(ctx context.Context, pool *pgxpool.Pool, state *seedState) error { tx, err := pool.Begin(ctx) if err != nil { return fmt.Errorf("begin monitoring tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if err := clearMonitoringData(ctx, tx, state); err != nil { return err } if err := insertSiteMappings(ctx, tx, state.TenantID); err != nil { return err } questionHashes := map[int64][]byte{ state.QuestionPrimaryID: makeQuestionHash("北京口腔医院推荐?"), state.QuestionSecondaryID: makeQuestionHash("北京种植牙哪家靠谱?"), state.QuestionTertiaryID: makeQuestionHash("北京牙齿矫正医院怎么选?"), } if err := insertQuestionSnapshots(ctx, tx, state, questionHashes); err != nil { return err } if err := insertPlatformAccessSnapshots(ctx, tx, state); err != nil { return err } if err := insertArticleAlias(ctx, tx, state); err != nil { return err } if err := insertDailySnapshots(ctx, tx, state); err != nil { return err } if err := insertPlatformDailySnapshots(ctx, tx, state); err != nil { return err } if err := insertTaskSeeds(ctx, tx, state, questionHashes); err != nil { return err } if err := insertRunSeeds(ctx, tx, state, questionHashes); err != nil { return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit monitoring seed: %w", err) } return nil } func ensureTenant(ctx context.Context, tx pgx.Tx) (int64, error) { return ensureNamedTenant(ctx, tx, "GEO Demo") } func ensureNamedTenant(ctx context.Context, tx pgx.Tx, name string) (int64, error) { var tenantID int64 err := tx.QueryRow(ctx, ` WITH ensured AS ( INSERT INTO tenants (name, status) VALUES ($1, 'active') ON CONFLICT DO NOTHING RETURNING id ) SELECT id FROM ensured UNION ALL SELECT id FROM tenants WHERE name = $1 LIMIT 1 `, name).Scan(&tenantID) return tenantID, wrapSeedErr("insert tenant", err) } func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) { return ensureNamedUser(ctx, tx, "admin@geo.local", "13800138000", "Admin", passwordHash) } func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, phone, name, passwordHash string) (int64, error) { var userID int64 err := tx.QueryRow(ctx, ` WITH ensured AS ( INSERT INTO users (email, phone, password_hash, name, status) VALUES ($1, $2, $3, $4, 'active') ON CONFLICT DO NOTHING RETURNING id ) SELECT id FROM ensured UNION ALL SELECT id FROM users WHERE email = $1 LIMIT 1 `, email, phone, passwordHash, name).Scan(&userID) return userID, wrapSeedErr("insert user", err) } func ensureMembership(ctx context.Context, tx pgx.Tx, tenantID, userID int64) error { return ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin") } func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID int64, role string) error { _, err := tx.Exec(ctx, ` INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING `, tenantID, userID, role) return wrapSeedErr("insert membership", err) } func ensureDefaultWorkspace(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) { var workspaceID int64 err := tx.QueryRow(ctx, ` INSERT INTO workspaces (tenant_id, name, slug, is_default) VALUES ($1, 'Default', 'default', TRUE) ON CONFLICT (tenant_id, slug) DO UPDATE SET name = EXCLUDED.name, is_default = TRUE, updated_at = NOW() RETURNING id `, tenantID).Scan(&workspaceID) return workspaceID, wrapSeedErr("insert workspace", err) } func ensurePrimaryWorkspaceMembership(ctx context.Context, tx pgx.Tx, workspaceID, tenantID, userID int64) error { if _, err := tx.Exec(ctx, ` UPDATE workspace_memberships SET is_primary = FALSE WHERE user_id = $1 AND tenant_id = $2 AND is_primary = TRUE AND workspace_id <> $3 `, userID, tenantID, workspaceID); err != nil { return wrapSeedErr("clear primary workspace", err) } _, err := tx.Exec(ctx, ` INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary) VALUES ($1, $2, $3, 'member', TRUE) ON CONFLICT (workspace_id, user_id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, role = EXCLUDED.role, is_primary = TRUE `, workspaceID, userID, tenantID) return wrapSeedErr("insert workspace membership", err) } func ensureSecondaryTenantUser( ctx context.Context, tx pgx.Tx, planID int64, defaultPlan config.MembershipPlanConfig, ) (int64, int64, error) { hash, err := bcrypt.GenerateFromPassword([]byte("Test@123"), bcrypt.DefaultCost) if err != nil { return 0, 0, fmt.Errorf("hash secondary user password: %w", err) } tenantID, err := ensureNamedTenant(ctx, tx, "test") if err != nil { return 0, 0, err } userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "13800138001", "test", string(hash)) if err != nil { return 0, 0, err } if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil { return 0, 0, err } workspaceID, err := ensureDefaultWorkspace(ctx, tx, tenantID) if err != nil { return 0, 0, err } if err := ensurePrimaryWorkspaceMembership(ctx, tx, workspaceID, tenantID, userID); err != nil { return 0, 0, err } if err := ensureSubscription(ctx, tx, tenantID, planID, defaultPlan.SubscriptionDuration); err != nil { return 0, 0, err } if err := ensureQuotaLedger(ctx, tx, tenantID, defaultPlan.ArticleGeneration); err != nil { return 0, 0, err } return tenantID, userID, nil } func ensurePlan(ctx context.Context, tx pgx.Tx, planCode string) (int64, error) { var planID int64 err := tx.QueryRow(ctx, ` SELECT id FROM plans WHERE plan_code = $1 AND deleted_at IS NULL LIMIT 1 `, planCode).Scan(&planID) return planID, wrapSeedErr("insert plan", err) } func ensureSubscription(ctx context.Context, tx pgx.Tx, tenantID, planID int64, duration time.Duration) error { if duration <= 0 { duration = 72 * time.Hour } _, err := tx.Exec(ctx, ` INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status) SELECT $1, $2, NOW(), NOW() + $3::interval, 'active' WHERE NOT EXISTS ( SELECT 1 FROM tenant_plan_subscriptions WHERE tenant_id = $1 AND plan_id = $2 AND status = 'active' AND deleted_at IS NULL AND end_at > NOW() ) `, tenantID, planID, duration.String()) return wrapSeedErr("insert subscription", err) } func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64, total int) error { _, err := tx.Exec(ctx, ` INSERT INTO tenant_quota_ledgers (tenant_id, quota_type, delta, balance_after, reason) SELECT $1, 'article_generation', $2, $2, 'initial_allocation' WHERE NOT EXISTS ( SELECT 1 FROM tenant_quota_ledgers WHERE tenant_id = $1 AND quota_type = 'article_generation' AND reason = 'initial_allocation' ) `, tenantID, total) return wrapSeedErr("insert quota", err) } func ensureTemplates(ctx context.Context, tx pgx.Tx) error { _, err := repository.EnsurePlatformTemplates(ctx, tx) return wrapSeedErr("ensure platform templates", err) } func ensureBrand(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) { var brandID int64 err := tx.QueryRow(ctx, ` WITH ensured AS ( INSERT INTO brands (tenant_id, name, website, description, status) VALUES ($1, '轻氧口腔', 'https://www.qingyang-dental.example', '北京口腔诊疗品牌示例', 'active') ON CONFLICT DO NOTHING RETURNING id ) SELECT id FROM ensured UNION ALL SELECT id FROM brands WHERE tenant_id = $1 AND name = '轻氧口腔' AND deleted_at IS NULL LIMIT 1 `, tenantID).Scan(&brandID) return brandID, wrapSeedErr("insert brand", err) } func ensureKeyword(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, name string) (int64, error) { var keywordID int64 err := tx.QueryRow(ctx, ` WITH ensured AS ( INSERT INTO brand_keywords (tenant_id, brand_id, name, status) VALUES ($1, $2, $3, 'active') ON CONFLICT DO NOTHING RETURNING id ) SELECT id FROM ensured UNION ALL SELECT id FROM brand_keywords WHERE tenant_id = $1 AND brand_id = $2 AND name = $3 AND deleted_at IS NULL LIMIT 1 `, tenantID, brandID, name).Scan(&keywordID) return keywordID, wrapSeedErr("insert keyword", err) } func ensureQuestion(ctx context.Context, tx pgx.Tx, tenantID, brandID, keywordID int64, questionText string) (int64, error) { var questionID int64 err := tx.QueryRow(ctx, ` SELECT id FROM brand_questions WHERE tenant_id = $1 AND brand_id = $2 AND keyword_id = $3 AND question_text = $4 AND deleted_at IS NULL LIMIT 1 `, tenantID, brandID, keywordID, questionText).Scan(&questionID) if err == nil { return questionID, nil } if err != pgx.ErrNoRows { return 0, wrapSeedErr("query question", err) } err = tx.QueryRow(ctx, ` INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status) VALUES ($1, $2, $3, $4, 'active') RETURNING id `, tenantID, brandID, keywordID, questionText).Scan(&questionID) return questionID, wrapSeedErr("insert question", err) } func ensureCompetitor(ctx context.Context, tx pgx.Tx, tenantID, brandID int64) error { _, err := tx.Exec(ctx, ` INSERT INTO competitors (tenant_id, brand_id, name, website, description, product_lines_json) SELECT $1, $2, '瑞尔齿科', 'https://www.arrailgroup.com', '北京连锁齿科竞品示例', '["牙齿种植","正畸"]'::jsonb WHERE NOT EXISTS ( SELECT 1 FROM competitors WHERE tenant_id = $1 AND brand_id = $2 AND name = '瑞尔齿科' AND deleted_at IS NULL ) `, tenantID, brandID) return wrapSeedErr("insert competitor", err) } func ensureArticle(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) { var articleID int64 err := tx.QueryRow(ctx, ` SELECT a.id FROM articles a JOIN article_versions av ON av.id = a.current_version_id WHERE a.tenant_id = $1 AND av.title = '北京口腔医院全面测评' AND a.deleted_at IS NULL LIMIT 1 `, tenantID).Scan(&articleID) if err == nil { return articleID, nil } if err != pgx.ErrNoRows { return 0, wrapSeedErr("query article", err) } err = tx.QueryRow(ctx, ` INSERT INTO articles (tenant_id, source_type, generate_status, publish_status) VALUES ($1, 'free_create', 'completed', 'published') RETURNING id `, tenantID).Scan(&articleID) if err != nil { return 0, wrapSeedErr("insert article", err) } var versionID int64 err = tx.QueryRow(ctx, ` INSERT INTO article_versions (article_id, version_no, title, markdown_content, word_count, source_label) VALUES ($1, 1, '北京口腔医院全面测评', '# 北京口腔医院全面测评', 1280, 'GEO Demo') RETURNING id `, articleID).Scan(&versionID) if err != nil { return 0, wrapSeedErr("insert article version", err) } _, err = tx.Exec(ctx, ` UPDATE articles SET current_version_id = $2, updated_at = NOW() WHERE id = $1 `, articleID, versionID) if err != nil { return 0, wrapSeedErr("update article current version", err) } return articleID, nil } func ensureDesktopClient(ctx context.Context, tx pgx.Tx, tenantID, workspaceID, userID int64) (uuid.UUID, error) { clientID := uuid.NewSHA1(uuid.NameSpaceOID, []byte("geo-rankly-demo-monitoring-client")) tokenHash := sha256.Sum256([]byte("demo-monitoring-client-token")) var ensuredID uuid.UUID err := tx.QueryRow(ctx, ` WITH ensured AS ( INSERT INTO desktop_clients ( id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, last_seen_at ) VALUES ( $1, $2, $3, $4, $5, 'GEO Monitoring Desktop', 'macOS', 'arm64', '0.1.0', 'stable', NOW() ) ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, workspace_id = EXCLUDED.workspace_id, user_id = EXCLUDED.user_id, token_hash = EXCLUDED.token_hash, device_name = EXCLUDED.device_name, os = EXCLUDED.os, cpu_arch = EXCLUDED.cpu_arch, client_version = EXCLUDED.client_version, channel = EXCLUDED.channel, last_seen_at = NOW(), revoked_at = NULL RETURNING id ) SELECT id FROM ensured UNION ALL SELECT id FROM desktop_clients WHERE id = $1 LIMIT 1 `, clientID, tenantID, workspaceID, userID, tokenHash[:]).Scan(&ensuredID) if err != nil { return uuid.Nil, wrapSeedErr("insert desktop client", err) } _, err = tx.Exec(ctx, ` UPDATE desktop_clients SET token_hash = $2, last_seen_at = NOW(), revoked_at = NULL WHERE id = $1 `, ensuredID, tokenHash[:]) if err != nil { return uuid.Nil, wrapSeedErr("touch desktop client", err) } return ensuredID, nil } func clearMonitoringData(ctx context.Context, tx pgx.Tx, state *seedState) error { pairs := []string{ `DELETE FROM monitoring_citation_facts WHERE tenant_id = $1 AND brand_id = $2`, `DELETE FROM question_monitor_parse_results WHERE tenant_id = $1 AND brand_id = $2`, `DELETE FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2`, `DELETE FROM monitoring_collect_tasks WHERE tenant_id = $1 AND brand_id = $2`, `DELETE FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2`, `DELETE FROM monitoring_brand_daily WHERE tenant_id = $1 AND brand_id = $2`, `DELETE FROM monitoring_question_config_snapshots WHERE tenant_id = $1 AND brand_id = $2`, } for _, query := range pairs { if _, err := tx.Exec(ctx, query, state.TenantID, state.BrandID); err != nil { return wrapSeedErr("clear monitoring data", err) } } singles := []string{ `DELETE FROM monitoring_platform_access_snapshots WHERE tenant_id = $1`, `DELETE FROM monitoring_article_url_aliases WHERE tenant_id = $1`, } for _, query := range singles { if _, err := tx.Exec(ctx, query, state.TenantID); err != nil { return wrapSeedErr("clear monitoring data", err) } } if _, err := tx.Exec(ctx, `DELETE FROM site_domain_mappings`); err != nil { return wrapSeedErr("clear site mappings", err) } return nil } func insertSiteMappings(ctx context.Context, tx pgx.Tx, _ int64) error { mappings := []struct { domain string siteKey string siteName string }{ {"zhihu.com", "zhihu.com", "知乎"}, {"weixin.qq.com", "weixin.qq.com", "微信公众号"}, {"haodf.com", "haodf.com", "好大夫在线"}, } for _, item := range mappings { if _, err := tx.Exec(ctx, ` INSERT INTO site_domain_mappings (registrable_domain, site_key, site_name, is_active) VALUES ($1, $2, $3, TRUE) ON CONFLICT (registrable_domain) DO UPDATE SET site_key = EXCLUDED.site_key, site_name = EXCLUDED.site_name, is_active = EXCLUDED.is_active, updated_at = NOW() `, item.domain, item.siteKey, item.siteName); err != nil { return wrapSeedErr("insert site mapping", err) } } return nil } func insertQuestionSnapshots(ctx context.Context, tx pgx.Tx, state *seedState, hashes map[int64][]byte) error { questions := []struct { ID int64 KeywordID int64 Text string }{ {state.QuestionPrimaryID, state.KeywordPrimaryID, "北京口腔医院推荐?"}, {state.QuestionSecondaryID, state.KeywordSecondaryID, "北京种植牙哪家靠谱?"}, {state.QuestionTertiaryID, state.KeywordPrimaryID, "北京牙齿矫正医院怎么选?"}, } for _, item := range questions { 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()) `, state.TenantID, state.BrandID, item.ID, item.KeywordID, hashes[item.ID], item.Text); err != nil { return wrapSeedErr("insert question snapshot", err) } } return nil } func insertPlatformAccessSnapshots(ctx context.Context, tx pgx.Tx, state *seedState) error { businessDate := time.Now().UTC().Format("2006-01-02") entries := []struct { PlatformID string AccessStatus string Connected bool Accessible bool ReasonCode *string ReasonMessage *string }{ {"deepseek", "accessible", true, true, nil, nil}, {"qwen", "not_logged_in", false, false, stringPtr("session_missing"), stringPtr("插件检测到通义千问登录态已失效")}, {"doubao", "accessible", true, true, nil, nil}, } for _, item := range entries { if _, err := tx.Exec(ctx, ` INSERT INTO monitoring_platform_access_snapshots ( tenant_id, client_id, ai_platform_id, business_date, access_status, connected, accessible, detected_at, reason_code, reason_message ) VALUES ($1, $2, $3, $4::date, $5, $6, $7, NOW(), $8, $9) `, state.TenantID, state.DesktopClientID, item.PlatformID, businessDate, item.AccessStatus, item.Connected, item.Accessible, item.ReasonCode, item.ReasonMessage); err != nil { return wrapSeedErr("insert platform access snapshot", err) } } return nil } func insertArticleAlias(ctx context.Context, tx pgx.Tx, state *seedState) error { _, err := tx.Exec(ctx, ` INSERT INTO monitoring_article_url_aliases ( tenant_id, article_id, platform_id, publish_platform_name_snapshot, article_title_snapshot, original_url, normalized_url, last_path_segment, confidence ) VALUES ( $1, $2, 'weixin_gzh', '微信公众号', '北京口腔医院全面测评', 'https://mp.weixin.qq.com/s/geo-demo-monitoring', 'https://mp.weixin.qq.com/s/geo-demo-monitoring', 'geo-demo-monitoring', 'high' ) `, state.TenantID, state.ArticleID) return wrapSeedErr("insert article alias", err) } func insertDailySnapshots(ctx context.Context, tx pgx.Tx, state *seedState) error { today := time.Now().UTC().Truncate(24 * time.Hour) seeds := []dailySeed{ {today.AddDate(0, 0, -6), "partial", 30, 18, 12, 0.6667, "medium", "automatic", 4, 2, 3, 7, 2, 0.3333, 0.1667, 0.2500, 0.5833, 0.1667}, {today.AddDate(0, 0, -5), "sampled", 30, 21, 16, 0.7619, "high", "automatic", 6, 3, 4, 10, 3, 0.3750, 0.1875, 0.2500, 0.6250, 0.1875}, {today.AddDate(0, 0, -4), "unsampled", 30, 0, 0, 0.0, "low", "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, {today.AddDate(0, 0, -3), "sampled", 30, 24, 18, 0.7500, "high", "automatic", 7, 4, 5, 12, 4, 0.3889, 0.2222, 0.2778, 0.6667, 0.2222}, {today.AddDate(0, 0, -2), "partial", 30, 21, 13, 0.6190, "medium", "automatic", 5, 2, 3, 9, 2, 0.3846, 0.1538, 0.2308, 0.6923, 0.1538}, {today.AddDate(0, 0, -1), "sampled", 30, 21, 15, 0.7143, "high", "automatic", 6, 3, 4, 10, 3, 0.4000, 0.2000, 0.2667, 0.6667, 0.2000}, {today, "sampled", 30, 21, 15, 0.7143, "high", "automatic", 7, 3, 4, 11, 4, 0.4667, 0.2000, 0.2667, 0.7333, 0.2667}, } for _, item := range seeds { var triggerSource interface{} var coverageRate interface{} var mentionRate interface{} var top1Rate interface{} var firstRate interface{} var positiveRate interface{} var citationRate interface{} var snapshotUpdatedAt interface{} if item.TriggerSource != "" { triggerSource = item.TriggerSource snapshotUpdatedAt = item.Date.Add(9*time.Hour + 20*time.Minute) } if item.ActualSampleCount > 0 { coverageRate = item.CoverageRate mentionRate = item.MentionRate top1Rate = item.Top1MentionRate firstRate = item.FirstRecommendRate positiveRate = item.PositiveMentionRate citationRate = item.CitationRate } _, err := tx.Exec(ctx, ` INSERT INTO monitoring_brand_daily ( tenant_id, brand_id, collector_type, business_date, sample_status, desired_sample_count, planned_sample_count, actual_sample_count, coverage_rate, confidence_level, trigger_source, snapshot_updated_at, mentioned_count, top1_mentioned_count, first_recommended_count, positive_mentioned_count, cited_answer_count, mention_rate, top1_mention_rate, first_recommend_rate, positive_mention_rate, citation_rate ) VALUES ( $1, $2, 'plugin', $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 ) `, state.TenantID, state.BrandID, item.Date.Format("2006-01-02"), item.SampleStatus, item.DesiredSampleCount, item.PlannedSampleCount, item.ActualSampleCount, coverageRate, item.ConfidenceLevel, triggerSource, snapshotUpdatedAt, item.MentionedCount, item.Top1MentionedCount, item.FirstRecommendedCount, item.PositiveMentionedCount, item.CitedAnswerCount, mentionRate, top1Rate, firstRate, positiveRate, citationRate, ) if err != nil { return wrapSeedErr("insert monitoring_brand_daily", err) } } return nil } func insertPlatformDailySnapshots(ctx context.Context, tx pgx.Tx, state *seedState) error { businessDate := time.Now().UTC().Format("2006-01-02") type platformDaily struct { ID string Status string Planned int Actual int MentionRate interface{} Top1Rate interface{} LastSampledAt interface{} } items := []platformDaily{ {"deepseek", "sampled", 8, 8, 0.5000, 0.2500, time.Now().UTC().Add(-25 * time.Minute)}, {"doubao", "sampled", 7, 7, 0.4286, 0.1429, time.Now().UTC().Add(-40 * time.Minute)}, {"qwen", "not_logged_in", 6, 0, nil, nil, nil}, } for _, item := range items { _, err := tx.Exec(ctx, ` INSERT INTO monitoring_brand_platform_daily ( tenant_id, brand_id, ai_platform_id, collector_type, business_date, platform_sample_status, planned_sample_count, actual_sample_count, mention_rate, top1_mention_rate, last_sampled_at ) VALUES ($1, $2, $3, 'plugin', $4::date, $5, $6, $7, $8, $9, $10) `, state.TenantID, state.BrandID, item.ID, businessDate, item.Status, item.Planned, item.Actual, item.MentionRate, item.Top1Rate, item.LastSampledAt) if err != nil { return wrapSeedErr("insert monitoring_brand_platform_daily", err) } } return nil } func insertTaskSeeds(ctx context.Context, tx pgx.Tx, state *seedState, hashes map[int64][]byte) error { businessDate := time.Now().UTC().Format("2006-01-02") tasks := []struct { QuestionID int64 PlatformID string Status string TriggerSource string SkipReason *string CompletedAt interface{} }{ {state.QuestionPrimaryID, "deepseek", "completed", "automatic", nil, time.Now().UTC().Add(-24 * time.Minute)}, {state.QuestionPrimaryID, "doubao", "completed", "automatic", nil, time.Now().UTC().Add(-36 * time.Minute)}, {state.QuestionPrimaryID, "qwen", "skipped", "automatic", stringPtr("platform_not_accessible"), nil}, {state.QuestionSecondaryID, "deepseek", "completed", "automatic", nil, time.Now().UTC().Add(-18 * time.Minute)}, {state.QuestionSecondaryID, "doubao", "pending", "automatic", nil, nil}, {state.QuestionSecondaryID, "qwen", "pending", "automatic", nil, nil}, {state.QuestionTertiaryID, "deepseek", "pending", "automatic", nil, nil}, {state.QuestionTertiaryID, "doubao", "completed", "automatic", nil, time.Now().UTC().Add(-42 * time.Minute)}, } for _, item := range tasks { _, 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, completed_at, skip_reason ) VALUES ( $1, $2, $3, $4, $5, 'plugin', $6, 'plugin_standard', $7::date, NOW(), $8, $9, $10 ) `, state.TenantID, state.BrandID, item.QuestionID, hashes[item.QuestionID], item.PlatformID, item.TriggerSource, businessDate, item.Status, item.CompletedAt, item.SkipReason) if err != nil { return wrapSeedErr("insert monitoring_collect_task", err) } } return nil } func insertRunSeeds(ctx context.Context, tx pgx.Tx, state *seedState, hashes map[int64][]byte) error { now := time.Now().UTC() runs := []monitoringRunSeed{ { BusinessDate: now, CompletedAt: now.Add(-25 * time.Minute), QuestionID: state.QuestionPrimaryID, QuestionText: "北京口腔医院推荐?", QuestionHash: hashes[state.QuestionPrimaryID], AIPlatformID: "deepseek", ProviderModel: "deepseek-chat", AnswerText: "根据口碑和诊疗体验,`轻氧口腔` 在医生沟通、正畸方案透明度和术后复诊上都比较稳妥。\n\n如果你更关注连锁规模,也可以对比瑞尔齿科。", BrandMentioned: true, MentionPosition: "top1", FirstRecommended: true, SentimentLabel: "positive", MatchedBrandTerms: []string{"轻氧口腔"}, Citations: []citationSeed{ { URL: "https://www.zhihu.com/question/123456", Title: "北京口腔医院哪家比较好?", NormalizedURL: "https://www.zhihu.com/question/123456", Host: "www.zhihu.com", RegistrableDomain: "zhihu.com", SiteKey: "zhihu.com", ResolutionStatus: "resolved", ResolutionConfidence: "high", }, { URL: "https://mp.weixin.qq.com/s/geo-demo-monitoring", Title: "北京口腔医院全面测评", NormalizedURL: "https://mp.weixin.qq.com/s/geo-demo-monitoring", Host: "mp.weixin.qq.com", RegistrableDomain: "weixin.qq.com", SiteKey: "weixin.qq.com", ArticleID: &state.ArticleID, ResolutionStatus: "resolved", ResolutionConfidence: "high", }, }, }, { BusinessDate: now, CompletedAt: now.Add(-38 * time.Minute), QuestionID: state.QuestionPrimaryID, QuestionText: "北京口腔医院推荐?", QuestionHash: hashes[state.QuestionPrimaryID], AIPlatformID: "doubao", ProviderModel: "doubao-seed-2-0-mini-260215", AnswerText: "如果你希望流程顺畅、医生解释细致,`轻氧口腔` 值得优先了解;如果预算非常敏感,可以再横向看看公立口腔医院。", BrandMentioned: true, MentionPosition: "mentioned", FirstRecommended: false, SentimentLabel: "positive", MatchedBrandTerms: []string{"轻氧口腔"}, Citations: []citationSeed{ { URL: "https://www.haodf.com/neirong/wenzhang/99887766.html", Title: "北京口腔医院就诊经验", NormalizedURL: "https://www.haodf.com/neirong/wenzhang/99887766.html", Host: "www.haodf.com", RegistrableDomain: "haodf.com", SiteKey: "haodf.com", ResolutionStatus: "resolved", ResolutionConfidence: "medium", }, }, }, { BusinessDate: now, CompletedAt: now.Add(-18 * time.Minute), QuestionID: state.QuestionSecondaryID, QuestionText: "北京种植牙哪家靠谱?", QuestionHash: hashes[state.QuestionSecondaryID], AIPlatformID: "deepseek", ProviderModel: "deepseek-chat", AnswerText: "在种植牙方案说明、价格透明度和术后复查安排上,`轻氧口腔` 的体验更稳定,适合作为首选参考。", BrandMentioned: true, MentionPosition: "top1", FirstRecommended: true, SentimentLabel: "positive", MatchedBrandTerms: []string{"轻氧口腔"}, Citations: []citationSeed{ { URL: "https://mp.weixin.qq.com/s/geo-demo-monitoring", Title: "北京口腔医院全面测评", NormalizedURL: "https://mp.weixin.qq.com/s/geo-demo-monitoring", Host: "mp.weixin.qq.com", RegistrableDomain: "weixin.qq.com", SiteKey: "weixin.qq.com", ArticleID: &state.ArticleID, ResolutionStatus: "resolved", ResolutionConfidence: "high", }, }, }, { BusinessDate: now, CompletedAt: now.Add(-42 * time.Minute), QuestionID: state.QuestionTertiaryID, QuestionText: "北京牙齿矫正医院怎么选?", QuestionHash: hashes[state.QuestionTertiaryID], AIPlatformID: "doubao", ProviderModel: "doubao-seed-2-0-mini-260215", AnswerText: "正畸更看重医生方案沟通和复诊便利性,`轻氧口腔` 的优势在于复诊节奏安排比较清晰。", BrandMentioned: true, MentionPosition: "mentioned", FirstRecommended: false, SentimentLabel: "positive", MatchedBrandTerms: []string{"轻氧口腔"}, Citations: []citationSeed{ { URL: "https://www.zhihu.com/question/9988123", Title: "牙齿矫正医院怎么挑?", NormalizedURL: "https://www.zhihu.com/question/9988123", Host: "www.zhihu.com", RegistrableDomain: "zhihu.com", SiteKey: "zhihu.com", ResolutionStatus: "resolved", ResolutionConfidence: "high", }, }, }, { BusinessDate: now.AddDate(0, 0, -1), CompletedAt: now.AddDate(0, 0, -1).Add(9*time.Hour + 18*time.Minute), QuestionID: state.QuestionPrimaryID, QuestionText: "北京口腔医院推荐?", QuestionHash: hashes[state.QuestionPrimaryID], AIPlatformID: "deepseek", ProviderModel: "deepseek-chat", AnswerText: "昨天的样本里,`轻氧口腔` 依然是优先推荐项,主要原因是体验稳定和医生解释清楚。", BrandMentioned: true, MentionPosition: "top1", FirstRecommended: true, SentimentLabel: "positive", MatchedBrandTerms: []string{"轻氧口腔"}, Citations: []citationSeed{ { URL: "https://www.zhihu.com/question/123456", Title: "北京口腔医院哪家比较好?", NormalizedURL: "https://www.zhihu.com/question/123456", Host: "www.zhihu.com", RegistrableDomain: "zhihu.com", SiteKey: "zhihu.com", ResolutionStatus: "resolved", ResolutionConfidence: "high", }, }, }, } for _, item := range runs { var runID int64 err := tx.QueryRow(ctx, ` INSERT INTO question_monitor_runs ( tenant_id, brand_id, question_id, question_text_snapshot, question_hash, ai_platform_id, collector_type, trigger_source, run_mode, business_date, provider_model, raw_answer_text, status, completed_at, raw_response_json ) VALUES ( $1, $2, $3, $4, $5, $6, 'plugin', 'automatic', 'plugin_standard', $7::date, $8, $9, 'succeeded', $10, $11::jsonb ) RETURNING id `, state.TenantID, state.BrandID, item.QuestionID, item.QuestionText, item.QuestionHash, item.AIPlatformID, item.BusinessDate.Format("2006-01-02"), item.ProviderModel, item.AnswerText, item.CompletedAt, mustJSON(map[string]any{"source": "dev-seed", "platform": item.AIPlatformID}), ).Scan(&runID) if err != nil { return wrapSeedErr("insert question_monitor_runs", err) } _, err = tx.Exec(ctx, ` INSERT INTO question_monitor_parse_results ( tenant_id, run_id, brand_id, question_id, question_hash, ai_platform_id, business_date, brand_mentioned, brand_mention_position, first_recommended, sentiment_label, matched_brand_terms, parse_version, parse_status ) VALUES ( $1, $2, $3, $4, $5, $6, $7::date, $8, $9, $10, $11, $12::jsonb, 'v1', 'succeeded' ) `, state.TenantID, runID, state.BrandID, item.QuestionID, item.QuestionHash, item.AIPlatformID, item.BusinessDate.Format("2006-01-02"), item.BrandMentioned, item.MentionPosition, item.FirstRecommended, item.SentimentLabel, mustJSON(item.MatchedBrandTerms), ) if err != nil { return wrapSeedErr("insert question_monitor_parse_results", err) } for _, citation := range item.Citations { _, err = tx.Exec(ctx, ` INSERT INTO monitoring_citation_facts ( tenant_id, run_id, brand_id, ai_platform_id, business_date, cited_url, cited_title, normalized_url, host, registrable_domain, site_key, article_id, publish_record_id, resolution_status, resolution_confidence ) VALUES ( $1, $2, $3, $4, $5::date, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 ) `, state.TenantID, runID, state.BrandID, item.AIPlatformID, item.BusinessDate.Format("2006-01-02"), citation.URL, citation.Title, citation.NormalizedURL, citation.Host, citation.RegistrableDomain, citation.SiteKey, citation.ArticleID, citation.PublishRecordID, citation.ResolutionStatus, citation.ResolutionConfidence, ) if err != nil { return wrapSeedErr("insert monitoring_citation_facts", err) } } } return nil } func makeQuestionHash(text string) []byte { normalized := strings.TrimSpace(strings.ToLower(text)) sum := sha256.Sum256([]byte(normalized)) return sum[:16] } func mustJSON(value any) string { data, _ := json.Marshal(value) return string(data) } func stringPtr(value string) *string { return &value } func wrapSeedErr(step string, err error) error { if err == nil { return nil } return fmt.Errorf("%s: %w", step, err) }