package creator import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/url" "strings" "time" "unicode/utf8" ) func validateHomepage(value string) error { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil { return ErrInvalid } return nil } func (s *Store) CreateCompetitor(ctx context.Context, input CompetitorInput) (Competitor, error) { input.Platform = strings.TrimSpace(input.Platform) input.PlatformAccountKey = strings.TrimSpace(input.PlatformAccountKey) input.Nickname = strings.TrimSpace(input.Nickname) input.AvatarURL = strings.TrimSpace(input.AvatarURL) input.HomepageURL = strings.TrimSpace(input.HomepageURL) if !ValidatePlatform(input.Platform) || input.PlatformAccountKey == "" || input.HomepageURL == "" || utf8.RuneCountInString(input.PlatformAccountKey) > 255 || utf8.RuneCountInString(input.Nickname) > 255 || utf8.RuneCountInString(input.AvatarURL) > 1000 || validateHomepage(input.HomepageURL) != nil { return Competitor{}, ErrInvalid } id := newID("competitor") if _, err := s.db.ExecContext(ctx, ` INSERT INTO creator_competitor (id, platform, platform_account_key, nickname, avatar_url, homepage_url, next_sync_at) VALUES ($1, $2, $3, $4, $5, $6, now())`, id, input.Platform, input.PlatformAccountKey, input.Nickname, input.AvatarURL, input.HomepageURL); err != nil { return Competitor{}, databaseError(err) } return s.GetCompetitor(ctx, id) } func scanCompetitor(scanner interface{ Scan(...any) error }) (Competitor, error) { var result Competitor var leaseUntil, lastSync, nextSync sql.NullTime if err := scanner.Scan(&result.ID, &result.Platform, &result.PlatformAccountKey, &result.Nickname, &result.AvatarURL, &result.HomepageURL, &result.Enabled, &result.SyncStatus, &result.SyncCursor, &result.SyncError, &leaseUntil, &lastSync, &nextSync, &result.CreatedAt, &result.UpdatedAt); err != nil { return Competitor{}, err } result.SyncLeaseUntil = nullableTime(leaseUntil) result.LastSyncAt = nullableTime(lastSync) result.NextSyncAt = nullableTime(nextSync) return result, nil } func (s *Store) GetCompetitor(ctx context.Context, id string) (Competitor, error) { result, err := scanCompetitor(s.db.QueryRowContext(ctx, ` SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, enabled, sync_status, sync_cursor, sync_error, sync_lease_until, last_sync_at, next_sync_at, created_at, updated_at FROM creator_competitor WHERE id = $1`, id)) return result, rowError(err) } func (s *Store) ListCompetitors(ctx context.Context, platform string) ([]Competitor, error) { query := `SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, enabled, sync_status, sync_cursor, sync_error, sync_lease_until, last_sync_at, next_sync_at, created_at, updated_at FROM creator_competitor` args := []any{} if platform != "" { if !ValidatePlatform(platform) { return nil, ErrInvalid } query += ` WHERE platform = $1` args = append(args, platform) } query += ` ORDER BY created_at DESC, id` rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]Competitor, 0) for rows.Next() { item, err := scanCompetitor(rows) if err != nil { return nil, err } result = append(result, item) } return result, rows.Err() } func (s *Store) SetCompetitorEnabled(ctx context.Context, id string, enabled bool) (Competitor, error) { status := "paused" if enabled { status = "idle" } _, err := s.db.ExecContext(ctx, ` UPDATE creator_competitor SET enabled = $2, sync_status = $3, sync_lease_until = NULL, next_sync_at = CASE WHEN $2 THEN now() ELSE NULL END, updated_at = now() WHERE id = $1`, id, enabled, status) if err != nil { return Competitor{}, databaseError(err) } return s.GetCompetitor(ctx, id) } func (s *Store) MarkCompetitorSync(ctx context.Context, id, leaseToken, status, cursor, syncError string, nextAt *time.Time) error { if id == "" || leaseToken == "" { return ErrInvalid } if status != "idle" && status != "running" && status != "paused" && status != "failed" && status != "blocked" { return ErrInvalid } if len(syncError) > 2000 || len(cursor) > 1000 { return ErrInvalid } var next any if nextAt != nil { next = nextAt.UTC() } result, err := s.db.ExecContext(ctx, ` UPDATE creator_competitor SET sync_status = $3, sync_cursor = $4, sync_error = $5, sync_lease_token = CASE WHEN $3 = 'running' THEN $2 ELSE NULL END, sync_lease_until = CASE WHEN $3 = 'running' THEN now() + interval '10 minutes' ELSE NULL END, last_sync_at = CASE WHEN $3 IN ('idle', 'failed', 'blocked') THEN now() ELSE last_sync_at END, next_sync_at = $6, updated_at = now() WHERE id = $1 AND sync_lease_token = $2`, id, leaseToken, status, cursor, syncError, next) if err != nil { return databaseError(err) } if affected, err := result.RowsAffected(); err != nil { return databaseError(err) } else if affected != 1 { return ErrConflict } return nil } func (s *Store) ClaimCompetitorSync(ctx context.Context, id string, force bool, now time.Time) (string, bool, error) { if id == "" || now.IsZero() { return "", false, ErrInvalid } condition := `enabled AND (next_sync_at IS NULL OR next_sync_at <= $2)` if force { condition = `enabled` } token := newID("competitor-lease") var claimed string err := s.db.QueryRowContext(ctx, `UPDATE creator_competitor SET sync_status='running', sync_lease_token=$2, sync_lease_until=$3 + interval '10 minutes', sync_error='', updated_at=$3 WHERE id=$1 AND `+strings.ReplaceAll(condition, "$2", "$3")+` AND (sync_status <> 'running' OR sync_lease_until IS NULL OR sync_lease_until <= $3) RETURNING id`, id, token, now.UTC()).Scan(&claimed) if errors.Is(err, sql.ErrNoRows) { return "", false, nil } if err != nil { return "", false, databaseError(err) } return token, claimed != "", nil } func (s *Store) ListDueCompetitors(ctx context.Context, now time.Time) ([]Competitor, error) { if now.IsZero() { return nil, ErrInvalid } rows, err := s.db.QueryContext(ctx, `SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, enabled, sync_status, sync_cursor, sync_error, sync_lease_until, last_sync_at, next_sync_at, created_at, updated_at FROM creator_competitor WHERE enabled AND next_sync_at IS NOT NULL AND next_sync_at <= $1 AND (sync_status <> 'running' OR sync_lease_until IS NULL OR sync_lease_until <= $1) ORDER BY COALESCE(next_sync_at, created_at), id`, now.UTC()) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]Competitor, 0) for rows.Next() { item, err := scanCompetitor(rows) if err != nil { return nil, err } result = append(result, item) } return result, rows.Err() } func (s *Store) validateWorkSource(ctx context.Context, platform, sourceType, sourceID string) error { var query string switch sourceType { case SourceOwned: query = `SELECT 1 FROM social_account WHERE id=$1 AND platform=$2` case SourceCompetitor: query = `SELECT 1 FROM creator_competitor WHERE id=$1 AND platform=$2` default: return ErrInvalid } var exists int if err := s.db.QueryRowContext(ctx, query, sourceID, platform).Scan(&exists); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrInvalid } return databaseError(err) } return nil } func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) (Work, bool, error) { input.Platform = strings.TrimSpace(input.Platform) input.WorkKey = strings.TrimSpace(input.WorkKey) input.SourceType = strings.TrimSpace(input.SourceType) input.SourceID = strings.TrimSpace(input.SourceID) input.AuthorName = strings.TrimSpace(input.AuthorName) input.Title = strings.TrimSpace(input.Title) input.OriginalURL = strings.TrimSpace(input.OriginalURL) input.CoverURL = strings.TrimSpace(input.CoverURL) if !ValidatePlatform(input.Platform) || input.WorkKey == "" || input.SourceID == "" || (input.SourceType != SourceOwned && input.SourceType != SourceCompetitor) || utf8.RuneCountInString(input.WorkKey) > 255 || utf8.RuneCountInString(input.Title) > 1000 || utf8.RuneCountInString(input.Body) > 100000 || utf8.RuneCountInString(input.OriginalURL) > 2000 || utf8.RuneCountInString(input.CoverURL) > 2000 { return Work{}, false, ErrInvalid } if err := s.validateWorkSource(ctx, input.Platform, input.SourceType, input.SourceID); err != nil { return Work{}, false, err } if now.IsZero() { now = time.Now().UTC() } status := input.PublishedAtStatus if status == "" { status = "verified" if input.PublishedAt == nil { status = "pending_verification" } } if status != "verified" && status != "pending_verification" && status != "invalid" && status != "future" { return Work{}, false, ErrInvalid } if input.PublishedAt != nil { input.PublishedAt = ptrTime(input.PublishedAt.UTC()) if input.PublishedAt.After(now.UTC()) && status == "verified" { status = "future" } } if input.Likes != nil && *input.Likes < 0 || input.CommentsCount != nil && *input.CommentsCount < 0 || input.Shares != nil && *input.Shares < 0 { return Work{}, false, ErrInvalid } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return Work{}, false, fmt.Errorf("begin creator work upsert: %w", err) } defer tx.Rollback() id := newID("work") var returnedID string var inserted bool err = tx.QueryRowContext(ctx, ` INSERT INTO creator_work (id, platform, work_key, source_type, source_id, author_name, title, body, published_at, published_at_status, original_url, cover_url, likes, comments_count, shares) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (platform, work_key) DO UPDATE SET author_name = CASE WHEN EXCLUDED.author_name = '' THEN creator_work.author_name ELSE EXCLUDED.author_name END, title = CASE WHEN EXCLUDED.title = '' THEN creator_work.title ELSE EXCLUDED.title END, body = CASE WHEN EXCLUDED.body = '' THEN creator_work.body ELSE EXCLUDED.body END, published_at = COALESCE(EXCLUDED.published_at, creator_work.published_at), published_at_status = CASE WHEN EXCLUDED.published_at IS NULL THEN creator_work.published_at_status ELSE EXCLUDED.published_at_status END, original_url = CASE WHEN EXCLUDED.original_url = '' THEN creator_work.original_url ELSE EXCLUDED.original_url END, cover_url = CASE WHEN EXCLUDED.cover_url = '' THEN creator_work.cover_url ELSE EXCLUDED.cover_url END, likes = COALESCE(EXCLUDED.likes, creator_work.likes), comments_count = COALESCE(EXCLUDED.comments_count, creator_work.comments_count), shares = COALESCE(EXCLUDED.shares, creator_work.shares), updated_at = now() RETURNING id, (xmax = 0)`, id, input.Platform, input.WorkKey, input.SourceType, input.SourceID, input.AuthorName, input.Title, input.Body, input.PublishedAt, status, input.OriginalURL, input.CoverURL, input.Likes, input.CommentsCount, input.Shares).Scan(&returnedID, &inserted) if err != nil { return Work{}, false, databaseError(err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO creator_work_source (work_id, platform, source_type, source_id) VALUES ($1, $2, $3, $4) ON CONFLICT (work_id, source_type, source_id) DO NOTHING`, returnedID, input.Platform, input.SourceType, input.SourceID); err != nil { return Work{}, false, databaseError(err) } result, err := scanWork(tx.QueryRowContext(ctx, workSelect+` WHERE id = $1`, returnedID)) if err != nil { return Work{}, false, rowError(err) } if err := tx.Commit(); err != nil { return Work{}, false, fmt.Errorf("commit creator work upsert: %w", err) } if err := s.loadWorkSources(ctx, &result); err != nil { return Work{}, false, err } return result, inserted, nil } func ptrTime(value time.Time) *time.Time { return &value } func scanWork(scanner interface{ Scan(...any) error }) (Work, error) { var result Work var publishedAt, latestAt, nextAt sql.NullTime var likes, commentsCount, shares sql.NullInt64 if err := scanner.Scan(&result.ID, &result.Platform, &result.WorkKey, &result.SourceType, &result.SourceID, &result.AuthorName, &result.Title, &result.Body, &publishedAt, &result.PublishedAtStatus, &result.OriginalURL, &result.CoverURL, &likes, &commentsCount, &shares, &latestAt, &nextAt, &result.MetricStopReason, &result.CreatedAt, &result.UpdatedAt); err != nil { return Work{}, err } result.PublishedAt = nullableTime(publishedAt) result.Likes, result.CommentsCount, result.Shares = nullableInt64(likes), nullableInt64(commentsCount), nullableInt64(shares) result.LatestMetricsAt, result.NextMetricAt = nullableTime(latestAt), nullableTime(nextAt) return result, nil } const workSelect = `SELECT id, platform, work_key, source_type, source_id, author_name, title, body, published_at, published_at_status, original_url, cover_url, likes, comments_count, shares, latest_metrics_at, next_metric_at, metric_stop_reason, created_at, updated_at FROM creator_work` func (s *Store) loadWorkSources(ctx context.Context, work *Work) error { rows, err := s.db.QueryContext(ctx, ` SELECT platform, source_type, source_id FROM creator_work_source WHERE work_id = $1 ORDER BY source_type, source_id`, work.ID) if err != nil { return databaseError(err) } defer rows.Close() work.Sources = make([]WorkSource, 0) for rows.Next() { var source WorkSource if err := rows.Scan(&source.Platform, &source.SourceType, &source.SourceID); err != nil { return err } work.Sources = append(work.Sources, source) } return rows.Err() } func (s *Store) GetWork(ctx context.Context, id string) (Work, error) { result, err := scanWork(s.db.QueryRowContext(ctx, workSelect+` WHERE id = $1`, id)) if err != nil { return Work{}, rowError(err) } if err := s.loadWorkSources(ctx, &result); err != nil { return Work{}, err } return result, nil } func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error) { query, args := workSelect, make([]any, 0, 8) where := make([]string, 0, 7) add := func(clause string, value any) { args = append(args, value) where = append(where, fmt.Sprintf(clause, len(args))) } if filter.Platform != "" { if !ValidatePlatform(filter.Platform) { return nil, ErrInvalid } add("platform = $%d", filter.Platform) } if filter.SourceType != "" { if filter.SourceType != SourceOwned && filter.SourceType != SourceCompetitor { return nil, ErrInvalid } args = append(args, filter.SourceType) where = append(where, fmt.Sprintf(`EXISTS (SELECT 1 FROM creator_work_source ws WHERE ws.work_id = creator_work.id AND ws.source_type = $%d)`, len(args))) } if filter.SourceID != "" { args = append(args, filter.SourceID) where = append(where, fmt.Sprintf(`EXISTS (SELECT 1 FROM creator_work_source ws WHERE ws.work_id = creator_work.id AND ws.source_id = $%d)`, len(args))) } if filter.PublishedAfter != nil { add("published_at >= $%d", filter.PublishedAfter.UTC()) } if filter.PublishedBefore != nil { add("published_at <= $%d", filter.PublishedBefore.UTC()) } if filter.MinLikes != nil { if *filter.MinLikes < 0 { return nil, ErrInvalid } where = append(where, fmt.Sprintf("likes IS NOT NULL AND likes >= $%d", len(args)+1)) args = append(args, *filter.MinLikes) } if filter.MinComments != nil { if *filter.MinComments < 0 { return nil, ErrInvalid } where = append(where, fmt.Sprintf("comments_count IS NOT NULL AND comments_count >= $%d", len(args)+1)) args = append(args, *filter.MinComments) } if filter.MinShares != nil { if *filter.MinShares < 0 { return nil, ErrInvalid } where = append(where, fmt.Sprintf("shares IS NOT NULL AND shares >= $%d", len(args)+1)) args = append(args, *filter.MinShares) } if len(where) > 0 { query += ` WHERE ` + strings.Join(where, ` AND `) } query += ` ORDER BY published_at DESC NULLS LAST, id` rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, databaseError(err) } result := make([]Work, 0) for rows.Next() { item, err := scanWork(rows) if err != nil { _ = rows.Close() return nil, err } result = append(result, item) } if err := rows.Err(); err != nil { _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { return nil, err } for index := range result { if err := s.loadWorkSources(ctx, &result[index]); err != nil { return nil, err } } return result, nil } func (s *Store) RecordMetric(ctx context.Context, input MetricInput, settings Settings, now time.Time) (MetricPoint, error) { if input.WorkID == "" || input.CollectedAt.IsZero() || input.Likes != nil && *input.Likes < 0 || input.CommentsCount != nil && *input.CommentsCount < 0 || input.Shares != nil && *input.Shares < 0 { return MetricPoint{}, ErrInvalid } if err := ValidateSettings(SettingsUpdate{LookbackDays: settings.LookbackDays, NewWorkIntervalSeconds: settings.NewWorkIntervalSeconds, MetricInitialIntervalSeconds: settings.MetricInitialIntervalSeconds, MetricMultiplier: settings.MetricMultiplier, MetricMaxIntervalSeconds: settings.MetricMaxIntervalSeconds, MetricAgeSeconds: settings.MetricAgeSeconds}); err != nil { return MetricPoint{}, err } if now.IsZero() { now = time.Now().UTC() } return s.recordMetricWithPlan(ctx, input, settings, now) } func nullableArg(value time.Time) any { if value.IsZero() { return nil } return value.UTC() } func (s *Store) ListMetrics(ctx context.Context, workID string) ([]MetricPoint, error) { rows, err := s.db.QueryContext(ctx, `SELECT collected_at, likes, comments_count, shares FROM creator_work_metric WHERE work_id = $1 ORDER BY collected_at`, workID) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]MetricPoint, 0) for rows.Next() { var point MetricPoint var likes, commentsCount, shares sql.NullInt64 if err := rows.Scan(&point.CollectedAt, &likes, &commentsCount, &shares); err != nil { return nil, err } point.CollectedAt = point.CollectedAt.UTC() point.Likes, point.CommentsCount, point.Shares = nullableInt64(likes), nullableInt64(commentsCount), nullableInt64(shares) result = append(result, point) } return result, rows.Err() } func (s *Store) GetMaterial(ctx context.Context, workID string) (MaterialJob, error) { if _, err := s.db.ExecContext(ctx, `INSERT INTO creator_material_job (work_id) VALUES ($1) ON CONFLICT (work_id) DO NOTHING`, workID); err != nil { return MaterialJob{}, databaseError(err) } return s.scanMaterial(s.db.QueryRowContext(ctx, `SELECT work_id, selected, select_confirmed_at, download_status, video_reference, audio_status, audio_reference, transcription_status, transcript, failed_step, failure_reason, rewrite_confirmed_at, rewrite_requirement, generated_title, generated_script, created_at, updated_at FROM creator_material_job WHERE work_id = $1`, workID)) } func (s *Store) scanMaterial(scanner interface{ Scan(...any) error }) (MaterialJob, error) { var result MaterialJob var selectedAt, rewriteAt sql.NullTime if err := scanner.Scan(&result.WorkID, &result.Selected, &selectedAt, &result.DownloadStatus, &result.VideoReference, &result.AudioStatus, &result.AudioReference, &result.TranscriptionStatus, &result.Transcript, &result.FailedStep, &result.FailureReason, &rewriteAt, &result.RewriteRequirement, &result.GeneratedTitle, &result.GeneratedScript, &result.CreatedAt, &result.UpdatedAt); err != nil { return MaterialJob{}, rowError(err) } result.SelectConfirmedAt, result.RewriteConfirmedAt = nullableTime(selectedAt), nullableTime(rewriteAt) return result, nil } func (s *Store) SelectMaterial(ctx context.Context, workID string) (MaterialJob, bool, error) { if _, err := s.GetWork(ctx, workID); err != nil { return MaterialJob{}, false, err } var inserted bool if err := s.db.QueryRowContext(ctx, `INSERT INTO creator_material_job (work_id, selected, select_confirmed_at) VALUES ($1, true, now()) ON CONFLICT (work_id) DO UPDATE SET selected = true, select_confirmed_at = COALESCE(creator_material_job.select_confirmed_at, now()), updated_at = now() RETURNING (xmax = 0)`, workID).Scan(&inserted); err != nil { return MaterialJob{}, false, databaseError(err) } job, err := s.GetMaterial(ctx, workID) return job, inserted, err } func (s *Store) SetMaterialStep(ctx context.Context, workID, step, status, reference, reason string) (MaterialJob, error) { if step != "download" && step != "audio" && step != "transcription" { return MaterialJob{}, ErrInvalid } valid := map[string]map[string]bool{ "download": {"not_started": true, "running": true, "succeeded": true, "failed": true}, "audio": {"not_started": true, "running": true, "succeeded": true, "no_audio": true, "failed": true}, "transcription": {"not_started": true, "running": true, "succeeded": true, "no_speech": true, "failed": true}, } if !valid[step][status] || len(reference) > 2000 || len(reason) > 2000 { return MaterialJob{}, ErrInvalid } job, err := s.GetMaterial(ctx, workID) if err != nil { return MaterialJob{}, err } if !job.Selected { return MaterialJob{}, ErrConflict } var query string var args []any switch step { case "download": query = `UPDATE creator_material_job SET download_status = $2, video_reference = $3, failed_step = CASE WHEN $2 = 'failed' THEN 'download' ELSE failed_step END, failure_reason = CASE WHEN $2 = 'failed' THEN $4 ELSE failure_reason END, updated_at = now() WHERE work_id = $1` args = []any{workID, status, reference, reason} case "audio": query = `UPDATE creator_material_job SET audio_status = $2, audio_reference = $3, failed_step = CASE WHEN $2 = 'failed' THEN 'audio' ELSE failed_step END, failure_reason = CASE WHEN $2 = 'failed' THEN $4 ELSE failure_reason END, updated_at = now() WHERE work_id = $1` args = []any{workID, status, reference, reason} case "transcription": query = `UPDATE creator_material_job SET transcription_status = $2, transcript = $3, failed_step = CASE WHEN $2 = 'failed' THEN 'transcription' ELSE failed_step END, failure_reason = CASE WHEN $2 = 'failed' THEN $4 ELSE failure_reason END, updated_at = now() WHERE work_id = $1` args = []any{workID, status, reference, reason} } if _, err := s.db.ExecContext(ctx, query, args...); err != nil { return MaterialJob{}, databaseError(err) } return s.GetMaterial(ctx, workID) } func (s *Store) ConfirmRewrite(ctx context.Context, workID, requirement string) (MaterialJob, error) { requirement = strings.TrimSpace(requirement) if utf8.RuneCountInString(requirement) > 4000 { return MaterialJob{}, ErrInvalid } job, err := s.GetMaterial(ctx, workID) if err != nil { return MaterialJob{}, err } if !job.Selected || job.DownloadStatus != "succeeded" || job.AudioStatus == "failed" || job.TranscriptionStatus == "failed" || job.AudioStatus == "running" || job.TranscriptionStatus == "running" || job.AudioStatus == "not_started" || job.TranscriptionStatus == "not_started" { return MaterialJob{}, ErrConflict } if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET rewrite_confirmed_at = now(), rewrite_requirement = $2, updated_at = now() WHERE work_id = $1`, workID, requirement); err != nil { return MaterialJob{}, databaseError(err) } return s.GetMaterial(ctx, workID) } func parseGeneratedRewrite(content string) (string, string, error) { var result struct { Title string `json:"title"` Script string `json:"script"` } if err := json.Unmarshal([]byte(strings.TrimSpace(content)), &result); err != nil { return "", "", fmt.Errorf("decode generated rewrite: %w", err) } result.Title, result.Script = strings.TrimSpace(result.Title), strings.TrimSpace(result.Script) if result.Title == "" || result.Script == "" || utf8.RuneCountInString(result.Title) > 1000 || utf8.RuneCountInString(result.Script) > 100000 { return "", "", ErrInvalid } return result.Title, result.Script, nil } func (s *Store) GenerateRewrite(ctx context.Context, workID string, generator TextGenerator) (MaterialJob, error) { if generator == nil { return MaterialJob{}, fmt.Errorf("%w: text generator is not configured", ErrUnavailable) } settings, err := s.GetSettings(ctx) if err != nil { return MaterialJob{}, err } if !settings.AIConfigured || strings.TrimSpace(settings.AIProvider) == "" || strings.TrimSpace(settings.AIModel) == "" { return MaterialJob{}, fmt.Errorf("%w: AI settings are not approved", ErrUnavailable) } job, err := s.GetMaterial(ctx, workID) if err != nil { return MaterialJob{}, err } if job.RewriteConfirmedAt == nil { return MaterialJob{}, ErrConflict } work, err := s.GetWork(ctx, workID) if err != nil { return MaterialJob{}, err } content, err := generator.Generate(ctx, "根据原作品和转写内容生成仿写草稿。只返回 JSON,不要 Markdown 或额外文字,格式必须是 {\"title\":\"标题\",\"script\":\"脚本\"}。不得编造原文没有的事实。", fmt.Sprintf("改写要求:%s\n原标题:%s\n原文案:%s\n转写:%s", job.RewriteRequirement, work.Title, work.Body, job.Transcript)) if err != nil { return MaterialJob{}, err } title, script, err := parseGeneratedRewrite(content) if err != nil { return MaterialJob{}, err } if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET generated_title = $2, generated_script = $3, updated_at = now() WHERE work_id = $1`, workID, title, script); err != nil { return MaterialJob{}, databaseError(err) } return s.GetMaterial(ctx, workID) } func (s *Store) SaveRewrite(ctx context.Context, workID, title, script string) (MaterialJob, error) { title, script = strings.TrimSpace(title), strings.TrimSpace(script) if title == "" || script == "" || utf8.RuneCountInString(title) > 1000 || utf8.RuneCountInString(script) > 100000 { return MaterialJob{}, ErrInvalid } job, err := s.GetMaterial(ctx, workID) if err != nil { return MaterialJob{}, err } if job.RewriteConfirmedAt == nil { return MaterialJob{}, ErrConflict } if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET generated_title = $2, generated_script = $3, updated_at = now() WHERE work_id = $1`, workID, title, script); err != nil { return MaterialJob{}, databaseError(err) } return s.GetMaterial(ctx, workID) } func (s *Store) SaveComment(ctx context.Context, input CommentInput) (Comment, bool, error) { input.Platform, input.CommentKey, input.WorkID, input.AuthorUID, input.AuthorName, input.CommentType = strings.TrimSpace(input.Platform), strings.TrimSpace(input.CommentKey), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.AuthorUID), strings.TrimSpace(input.AuthorName), strings.TrimSpace(input.CommentType) if !ValidatePlatform(input.Platform) || input.CommentKey == "" || input.WorkID == "" || strings.TrimSpace(input.Content) == "" || (input.CommentType != "top_level" && input.CommentType != "unknown") || utf8.RuneCountInString(input.Content) > 10000 { return Comment{}, false, ErrInvalid } work, err := s.GetWork(ctx, input.WorkID) if err != nil { return Comment{}, false, err } if work.Platform != input.Platform { return Comment{}, false, ErrInvalid } id := newID("comment") var returnedID string var inserted bool err = s.db.QueryRowContext(ctx, `INSERT INTO creator_comment (id, platform, comment_key, work_id, author_uid, author_name, content, published_at, comment_type) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (platform, comment_key) DO UPDATE SET work_id = EXCLUDED.work_id, author_uid = EXCLUDED.author_uid, author_name = EXCLUDED.author_name, content = EXCLUDED.content, published_at = EXCLUDED.published_at, comment_type = EXCLUDED.comment_type RETURNING id, (xmax = 0)`, id, input.Platform, input.CommentKey, input.WorkID, input.AuthorUID, input.AuthorName, input.Content, input.PublishedAt, input.CommentType).Scan(&returnedID, &inserted) if err != nil { return Comment{}, false, databaseError(err) } result, err := s.GetComment(ctx, returnedID) return result, inserted, err } func scanComment(scanner interface{ Scan(...any) error }) (Comment, error) { var result Comment var publishedAt sql.NullTime if err := scanner.Scan(&result.ID, &result.Platform, &result.CommentKey, &result.WorkID, &result.AuthorUID, &result.AuthorName, &result.Content, &publishedAt, &result.CollectedAt, &result.CommentType); err != nil { return Comment{}, err } result.PublishedAt = nullableTime(publishedAt) result.CollectedAt = result.CollectedAt.UTC() return result, nil } const commentSelect = `SELECT id, platform, comment_key, work_id, author_uid, author_name, content, published_at, collected_at, comment_type FROM creator_comment` func (s *Store) GetComment(ctx context.Context, id string) (Comment, error) { result, err := scanComment(s.db.QueryRowContext(ctx, commentSelect+` WHERE id = $1`, id)) return result, rowError(err) } func pageBounds(page, pageSize int) (int, int, error) { if page < 1 || pageSize < 1 || pageSize > 100 { return 0, 0, ErrInvalid } if page > int(maxDurationSeconds) || pageSize > int(maxDurationSeconds)/page { return 0, 0, ErrInvalid } return (page - 1) * pageSize, pageSize, nil } func slicePage[T any](items []T, page, pageSize int) (Page[T], error) { offset, limit, err := pageBounds(page, pageSize) if err != nil { return Page[T]{}, err } if offset > len(items) { offset = len(items) } end := offset + limit if end > len(items) { end = len(items) } data := append([]T(nil), items[offset:end]...) return Page[T]{Data: data, Page: page, PageSize: pageSize, Total: len(items), HasNext: end < len(items)}, nil } func (s *Store) ListWorksPage(ctx context.Context, filter WorkFilter, page, pageSize int) (Page[Work], error) { items, err := s.ListWorks(ctx, filter) if err != nil { return Page[Work]{}, err } return slicePage(items, page, pageSize) } func (s *Store) ListCommentsPage(ctx context.Context, platform, workID string, page, pageSize int) (Page[Comment], error) { items, err := s.ListComments(ctx, platform, workID) if err != nil { return Page[Comment]{}, err } return slicePage(items, page, pageSize) } func (s *Store) ListComments(ctx context.Context, platform, workID string) ([]Comment, error) { query, args := commentSelect, []any{} where := []string{} if platform != "" { if !ValidatePlatform(platform) { return nil, ErrInvalid } args = append(args, platform) where = append(where, fmt.Sprintf("platform = $%d", len(args))) } if workID != "" { args = append(args, workID) where = append(where, fmt.Sprintf("work_id = $%d", len(args))) } if len(where) > 0 { query += ` WHERE ` + strings.Join(where, ` AND `) } query += ` ORDER BY published_at DESC NULLS LAST, collected_at DESC, id` rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]Comment, 0) for rows.Next() { item, err := scanComment(rows) if err != nil { return nil, err } result = append(result, item) } return result, rows.Err() }