package creator import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" ) func encodeRuleLists(input LeadRuleInput) (string, string, error) { include, err := jsonText(input.IncludeKeywords) if err != nil { return "", "", err } exclude, err := jsonText(input.ExcludeKeywords) if err != nil { return "", "", err } return include, exclude, nil } func scanRule(scanner interface{ Scan(...any) error }) (LeadRule, error) { var result LeadRule var include, exclude []byte if err := scanner.Scan(&result.ID, &result.Name, &result.Enabled, &result.SourceType, &result.Topic, &include, &exclude, &result.AIRequirement, &result.UpdatedAt, &result.CreatedAt); err != nil { return LeadRule{}, err } var err error result.IncludeKeywords, err = decodeStringList(include) if err != nil { return LeadRule{}, err } result.ExcludeKeywords, err = decodeStringList(exclude) if err != nil { return LeadRule{}, err } return result, nil } const ruleSelect = `SELECT id, name, enabled, source_type, topic, include_keywords, exclude_keywords, ai_requirement, updated_at, created_at FROM creator_lead_rule` func (s *Store) CreateRule(ctx context.Context, input LeadRuleInput) (LeadRule, error) { input, err := ValidateRuleInput(input) if err != nil { return LeadRule{}, err } include, exclude, err := encodeRuleLists(input) if err != nil { return LeadRule{}, err } id := newID("rule") if _, err := s.db.ExecContext(ctx, `INSERT INTO creator_lead_rule (id,name,enabled,source_type,topic,include_keywords,exclude_keywords,ai_requirement) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8)`, id, input.Name, input.Enabled, input.SourceType, input.Topic, include, exclude, input.AIRequirement); err != nil { return LeadRule{}, databaseError(err) } return s.GetRule(ctx, id) } func (s *Store) GetRule(ctx context.Context, id string) (LeadRule, error) { result, err := scanRule(s.db.QueryRowContext(ctx, ruleSelect+` WHERE id = $1`, id)) return result, rowError(err) } func (s *Store) ListRules(ctx context.Context, enabledOnly bool) ([]LeadRule, error) { query := ruleSelect if enabledOnly { query += ` WHERE enabled` } query += ` ORDER BY created_at DESC,id` rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]LeadRule, 0) for rows.Next() { item, err := scanRule(rows) if err != nil { return nil, err } result = append(result, item) } return result, rows.Err() } func (s *Store) UpdateRule(ctx context.Context, id string, input LeadRuleInput) (LeadRule, error) { input, err := ValidateRuleInput(input) if err != nil { return LeadRule{}, err } include, exclude, err := encodeRuleLists(input) if err != nil { return LeadRule{}, err } _, err = s.db.ExecContext(ctx, `UPDATE creator_lead_rule SET name=$2,enabled=$3,source_type=$4,topic=$5,include_keywords=$6::jsonb,exclude_keywords=$7::jsonb,ai_requirement=$8,updated_at=now() WHERE id=$1`, id, input.Name, input.Enabled, input.SourceType, input.Topic, include, exclude, input.AIRequirement) if err != nil { return LeadRule{}, databaseError(err) } return s.GetRule(ctx, id) } func (s *Store) SetRuleEnabled(ctx context.Context, id string, enabled bool) (LeadRule, error) { if _, err := s.db.ExecContext(ctx, `UPDATE creator_lead_rule SET enabled=$2,updated_at=now() WHERE id=$1`, id, enabled); err != nil { return LeadRule{}, databaseError(err) } return s.GetRule(ctx, id) } func (s *Store) upsertRuleResult(ctx context.Context, commentID string, rule LeadRule, status, reason string, matched []string, analysedAt *time.Time) (RuleResult, error) { snapshot, err := jsonText(rule) if err != nil { return RuleResult{}, err } matchedJSON, err := jsonText(matched) if err != nil { return RuleResult{}, err } _, err = s.db.ExecContext(ctx, `INSERT INTO creator_comment_rule_result (comment_id,rule_id,status,reason,matched_keywords,rule_snapshot,analysed_at) VALUES ($1,$2,$3,$4,$5::jsonb,$6::jsonb,$7) ON CONFLICT (comment_id,rule_id) DO UPDATE SET status=EXCLUDED.status,reason=EXCLUDED.reason,matched_keywords=EXCLUDED.matched_keywords,rule_snapshot=EXCLUDED.rule_snapshot,analysed_at=EXCLUDED.analysed_at`, commentID, rule.ID, status, reason, matchedJSON, snapshot, analysedAt) if err != nil { return RuleResult{}, databaseError(err) } return RuleResult{CommentID: commentID, RuleID: rule.ID, Status: status, Reason: reason, MatchedKeywords: matched, RuleSnapshot: rule, AnalysedAt: analysedAt}, nil } func (s *Store) AnalyzeComments(ctx context.Context, commentIDs []string, ruleID string, analyzer ThemeAnalyzer) ([]RuleAnalysisItem, error) { if len(commentIDs) == 0 || len(commentIDs) > 100 || strings.TrimSpace(ruleID) == "" { return nil, ErrInvalid } seen := make(map[string]struct{}, len(commentIDs)) items := make([]RuleAnalysisItem, 0, len(commentIDs)) var batchErr error for _, commentID := range commentIDs { commentID = strings.TrimSpace(commentID) if commentID == "" { return nil, ErrInvalid } if _, ok := seen[commentID]; ok { continue } seen[commentID] = struct{}{} result, err := s.AnalyzeComment(ctx, commentID, ruleID, analyzer) item := RuleAnalysisItem{CommentID: commentID, Result: &result} if err != nil { item.Result = nil item.Error = err.Error() batchErr = errors.Join(batchErr, err) } items = append(items, item) } return items, batchErr } func (s *Store) AnalyzeComment(ctx context.Context, commentID, ruleID string, analyzer ThemeAnalyzer) (RuleResult, error) { comment, err := s.GetComment(ctx, commentID) if err != nil { return RuleResult{}, err } rule, err := s.GetRule(ctx, ruleID) if err != nil { return RuleResult{}, err } if !rule.Enabled { return RuleResult{}, ErrConflict } work, err := s.GetWork(ctx, comment.WorkID) if err != nil { return RuleResult{}, err } if rule.SourceType != "all" { matchedSource := false for _, source := range work.Sources { if source.SourceType == rule.SourceType { matchedSource = true break } } if !matchedSource && work.SourceType != rule.SourceType { return RuleResult{}, ErrConflict } } if analyzer == nil { result, saveErr := s.upsertRuleResult(ctx, commentID, rule, "failed", "AI 分析不可用", nil, ptrTime(time.Now().UTC())) if saveErr != nil { return RuleResult{}, saveErr } return result, ErrUnavailable } theme, reason, err := analyzer.MatchTheme(ctx, work.Title, work.Body, rule.Topic) if err != nil { result, saveErr := s.upsertRuleResult(ctx, commentID, rule, "failed", err.Error(), nil, ptrTime(time.Now().UTC())) if saveErr != nil { return RuleResult{}, saveErr } return result, err } if !theme { return s.upsertRuleResult(ctx, commentID, rule, "topic_no_match", strings.TrimSpace(reason), nil, ptrTime(time.Now().UTC())) } keywordsOK, matched := MatchKeywords(comment.Content, rule.IncludeKeywords, rule.ExcludeKeywords) if !keywordsOK { return s.upsertRuleResult(ctx, commentID, rule, "keyword_no_match", "关键词不满足", matched, ptrTime(time.Now().UTC())) } lead, reason, err := analyzer.MatchLead(ctx, work.Title+"\n"+work.Body, comment.Content, rule.AIRequirement) if err != nil { result, saveErr := s.upsertRuleResult(ctx, commentID, rule, "failed", err.Error(), matched, ptrTime(time.Now().UTC())) if saveErr != nil { return RuleResult{}, saveErr } return result, err } if !lead { return s.upsertRuleResult(ctx, commentID, rule, "not_lead", strings.TrimSpace(reason), matched, ptrTime(time.Now().UTC())) } return s.upsertRuleResult(ctx, commentID, rule, "lead", strings.TrimSpace(reason), matched, ptrTime(time.Now().UTC())) } func scanRuleResult(scanner interface{ Scan(...any) error }) (RuleResult, error) { var r RuleResult var matched, snapshot []byte var analysed sql.NullTime if err := scanner.Scan(&r.CommentID, &r.RuleID, &r.Status, &r.Reason, &matched, &snapshot, &analysed); err != nil { return RuleResult{}, err } var err error r.MatchedKeywords, err = decodeStringList(matched) if err != nil { return RuleResult{}, err } if err := jsonUnmarshal(snapshot, &r.RuleSnapshot); err != nil { return RuleResult{}, err } r.AnalysedAt = nullableTime(analysed) return r, nil } func jsonUnmarshal(data []byte, value any) error { if len(data) == 0 { return nil } return json.Unmarshal(data, value) } func (s *Store) ListRuleResults(ctx context.Context, commentID, ruleID string) ([]RuleResult, error) { query := `SELECT comment_id,rule_id,status,reason,matched_keywords,rule_snapshot,analysed_at FROM creator_comment_rule_result` where := []string{} args := []any{} if commentID != "" { args = append(args, commentID) where = append(where, fmt.Sprintf("comment_id = $%d", len(args))) } if ruleID != "" { args = append(args, ruleID) where = append(where, fmt.Sprintf("rule_id = $%d", len(args))) } if len(where) > 0 { query += ` WHERE ` + strings.Join(where, ` AND `) } query += ` ORDER BY analysed_at DESC NULLS LAST,comment_id,rule_id` rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]RuleResult, 0) for rows.Next() { item, err := scanRuleResult(rows) if err != nil { return nil, err } result = append(result, item) } return result, rows.Err() } func (s *Store) ListLeads(ctx context.Context, platform string) ([]Lead, error) { comments, err := s.ListComments(ctx, platform, "") if err != nil { return nil, err } results, err := s.ListRuleResults(ctx, "", "") if err != nil { return nil, err } byComment := map[string][]RuleResult{} for _, result := range results { if result.Status == "lead" { byComment[result.CommentID] = append(byComment[result.CommentID], result) } } leads := make([]Lead, 0) for _, comment := range comments { matched := byComment[comment.ID] if len(matched) == 0 { continue } ids := make([]string, 0, len(matched)) for _, result := range matched { ids = append(ids, result.RuleID) } leads = append(leads, Lead{Comment: comment, RuleIDs: ids, Results: matched}) } return leads, nil }