Files
geo/server/cmd/dev-seed/main.go
T
root 63667ed2d1 feat(membership): enforce tenant subscription plans with blocked UI
Add configurable membership plans (free/plus/pro) synced to DB on boot, a
subscription guard middleware that blocks tenant endpoints on expired or
missing plans, and a MembershipBlockedView that surfaces the reason so
the admin can contact the tenant owner. Quota and brand-library reads now
honor the active plan's policy JSON and expired subscriptions.
2026-04-18 20:56:05 +08:00

1143 lines
39 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
"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
TestTenantID int64
TestUserID int64
PlanID int64
BrandID int64
KeywordPrimaryID int64
KeywordSecondaryID int64
QuestionPrimaryID int64
QuestionSecondaryID int64
QuestionTertiaryID int64
ArticleID int64
PluginInstallationID int64
}
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(" Plugin Installation ID: %d\n", state.PluginInstallationID)
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.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.PluginInstallationID, err = ensurePluginInstallation(ctx, tx, state.TenantID, state.UserID)
if err != nil {
return nil, err
}
if err := ensureMonitoringQuota(ctx, tx, state); 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", "Admin", passwordHash)
}
func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash string) (int64, error) {
var userID int64
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO users (email, password_hash, name, status)
VALUES ($1, $2, $3, 'active')
ON CONFLICT DO NOTHING
RETURNING id
)
SELECT id FROM ensured
UNION ALL
SELECT id FROM users WHERE email = $1
LIMIT 1
`, email, 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 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", "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
}
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 {
for _, tpl := range prompts.PlatformTemplateSeeds() {
_, err := tx.Exec(ctx, `
INSERT INTO article_templates (scope, origin_type, template_key, template_name, prompt_template, prompt_visibility, card_config_json, status)
VALUES ('platform', 'platform', $1, $2, $3, 'visible', $4::jsonb, 'active')
ON CONFLICT DO NOTHING
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
if err != nil {
return wrapSeedErr("insert template", err)
}
_, err = tx.Exec(ctx, `
UPDATE article_templates
SET template_name = $2,
prompt_template = $3,
card_config_json = $4::jsonb,
prompt_visibility = 'visible',
status = 'active',
updated_at = NOW()
WHERE scope = 'platform'
AND template_key = $1
AND deleted_at IS NULL
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
if err != nil {
return wrapSeedErr("update template", err)
}
}
return nil
}
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 ensurePluginInstallation(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) {
installationKey := "demo-monitoring-installation"
tokenHash := sharedauth.HashToken("demo-monitoring-installation-token")
var installationID int64
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO plugin_installations (
tenant_id, user_id, installation_key, installation_name, browser_name,
client_version, installation_token_hash, status, last_seen_at
)
VALUES (
$1, $2, $3, 'GEO Monitoring Chrome', 'Chrome',
'0.1.0', $4, 'active', NOW()
)
ON CONFLICT DO NOTHING
RETURNING id
)
SELECT id FROM ensured
UNION ALL
SELECT id
FROM plugin_installations
WHERE tenant_id = $1 AND installation_key = $3 AND deleted_at IS NULL
LIMIT 1
`, tenantID, userID, installationKey, tokenHash).Scan(&installationID)
if err != nil {
return 0, wrapSeedErr("insert plugin installation", err)
}
_, err = tx.Exec(ctx, `
UPDATE plugin_installations
SET status = 'active',
installation_token_hash = $2,
last_seen_at = NOW(),
updated_at = NOW()
WHERE id = $1
`, installationID, tokenHash)
if err != nil {
return 0, wrapSeedErr("touch plugin installation", err)
}
return installationID, nil
}
func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) error {
enabledPlatforms, _ := json.Marshal([]string{"deepseek", "qwen", "doubao"})
_, err := tx.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas (
tenant_id, max_brands, collection_mode, question_selection_mode, collect_frequency,
enabled_platforms, primary_installation_id, task_daily_hard_cap, plan_tier
)
VALUES ($1, 1, 'plugin', 'best_effort_all', 'daily', $2::jsonb, $3, 36, 'free')
ON CONFLICT (tenant_id) DO UPDATE
SET collection_mode = EXCLUDED.collection_mode,
question_selection_mode = EXCLUDED.question_selection_mode,
collect_frequency = EXCLUDED.collect_frequency,
enabled_platforms = EXCLUDED.enabled_platforms,
primary_installation_id = EXCLUDED.primary_installation_id,
task_daily_hard_cap = EXCLUDED.task_daily_hard_cap,
plan_tier = EXCLUDED.plan_tier,
updated_at = NOW()
`, state.TenantID, string(enabledPlatforms), state.PluginInstallationID)
return wrapSeedErr("upsert monitoring quota", err)
}
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`,
`DELETE FROM site_domain_mappings WHERE tenant_id = $1`,
}
for _, query := range singles {
if _, err := tx.Exec(ctx, query, state.TenantID); err != nil {
return wrapSeedErr("clear monitoring data", err)
}
}
return nil
}
func insertSiteMappings(ctx context.Context, tx pgx.Tx, tenantID 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 (tenant_id, registrable_domain, site_key, site_name, is_active)
VALUES ($1, $2, $3, $4, TRUE)
`, tenantID, 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, installation_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.PluginInstallationID, 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)
}