package job import ( "context" "fmt" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/placement" libviral "haixun-backend/internal/library/viral" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" jobdom "haixun-backend/internal/model/job/domain/usecase" personaentity "haixun-backend/internal/model/persona/domain/entity" personadomain "haixun-backend/internal/model/persona/domain/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type ScanViralDeps struct { Jobs jobdom.UseCase Persona personadomain.UseCase ScanPost scanpostusecase.UseCase ThreadsAccount threadsaccountdomain.UseCase Placement placementusecase.UseCase AI aiusecase.UseCase } func RegisterScanViralHandler(runner *Runner, deps ScanViralDeps) { if runner == nil { return } runner.RegisterStepHandler("viral_crawl", func(ctx context.Context, step StepContext) error { return runScanViral(ctx, step, deps) }) } func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) error { payload := step.Run.Payload tenantID := stringField(payload, "tenant_id") ownerUID := stringField(payload, "owner_uid") personaID := personaIDFromPayload(payload) if tenantID == "" || ownerUID == "" || personaID == "" { return fmt.Errorf("scan-viral payload missing tenant_id, owner_uid, or persona_id") } persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) if err != nil { return err } research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) if err != nil { return err } memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research) if err != nil { return err } if !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { return fmt.Errorf("爆款掃描需要 Threads API 或 Chrome Session(開發模式)") } if memberCtx.DevMode && !memberCtx.BrowserConnected { return fmt.Errorf("開發模式需先同步 Chrome Session") } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Phase: "viral_crawl", Summary: summary, Percentage: percentage, }) } bootstrap := boolField(payload, "bootstrap") if bootstrap && persona.CopyResearchMap.AudienceSummary == "" && len(persona.CopyResearchMap.SuggestedTags) == 0 { updateProgress("產生拷貝忍者研究地圖…", 8) if err := ensureCopyResearchMap(ctx, deps, tenantID, ownerUID, persona, memberCtx, updateProgress); err != nil { return err } persona, err = deps.Persona.Get(ctx, tenantID, ownerUID, personaID) if err != nil { return err } } keywords := stringSliceField(payload, "keywords") if len(keywords) == 0 { keywords = deriveViralKeywords(persona) } if len(keywords) == 0 { return fmt.Errorf("請提供爆款掃描關鍵字,或先完成研究地圖/對標帳號") } exclusions := persona.CopyResearchMap.Exclusions updateProgress("準備爆款掃描…", 12) crawlerFn := makeCrawlerSearchFn(ScanPlacementDeps{ThreadsAccount: deps.ThreadsAccount}, tenantID, ownerUID) candidates, err := libviral.RunDiscover(ctx, libviral.DiscoverInput{ Keywords: keywords, Exclusions: exclusions, Member: memberCtx, Crawler: crawlerFn, }, updateProgress) if err != nil { return err } updateProgress(fmt.Sprintf("寫入 %d 篇爆款候選…", len(candidates)), 92) count, err := deps.ScanPost.ReplaceFromViralScan(ctx, scanpostusecase.ViralReplaceRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, ScanJobID: step.JobID, Posts: candidates, }) if err != nil { return err } handoff := map[string]any{ "flow": "copy", "persona_id": personaID, "summary": fmt.Sprintf("爆款掃描完成:%d 篇候選", count), "next_route": "/matrix", } _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Result: map[string]any{ "post_count": count, "handoff": handoff, }, }) return err } func ensureCopyResearchMap( ctx context.Context, deps ScanViralDeps, tenantID, ownerUID string, persona *personadomain.PersonaSummary, memberCtx placement.MemberContext, updateProgress func(string, int), ) error { if persona == nil { return fmt.Errorf("copy research map: missing persona") } credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) if err != nil { return err } providerID, err := aiusecase.MapWorkerProvider(credential.Provider) if err != nil { return err } label := strings.TrimSpace(persona.DisplayName) if label == "" { label = "拷貝主題" } seed := strings.TrimSpace(persona.SeedQuery) if seed == "" { seed = strings.TrimPrefix(strings.TrimSpace(persona.StyleBenchmark), "@") } if seed == "" { for _, line := range strings.Split(strings.TrimSpace(persona.Brief), "\n") { line = strings.TrimSpace(line) if line != "" { seed = line break } } } result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: libviral.BuildCopyResearchMapSystemPrompt(), Messages: []domai.Message{ { Role: "user", Content: libviral.BuildCopyResearchMapUserPrompt(libviral.CopyResearchMapInput{ Label: label, SeedQuery: seed, Brief: persona.Brief, Persona: persona.Persona, StyleBenchmark: persona.StyleBenchmark, }), }, }, }) if err != nil { return err } parsed, err := libviral.ParseCopyResearchMapOutput(result.Text) if err != nil { return app.For(code.AI).SvcThirdParty("拷貝研究地圖 LLM 回傳無法解析:" + err.Error()) } entityMap := personaentity.CopyResearchMap{ AudienceSummary: parsed.AudienceSummary, ContentGoal: parsed.ContentGoal, Questions: parsed.Questions, Pillars: parsed.Pillars, Exclusions: parsed.Exclusions, SuggestedTags: parsed.SuggestedTags, BenchmarkNotes: parsed.BenchmarkNotes, } patch := personadomain.PersonaPatch{ CopyResearchMap: &entityMap, } if seed != "" && strings.TrimSpace(persona.SeedQuery) == "" { patch.SeedQuery = &seed } _, err = deps.Persona.Update(ctx, personadomain.UpdateRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: persona.ID, Patch: patch, }) if err != nil { return err } if updateProgress != nil { updateProgress("研究地圖已就緒", 10) } _ = memberCtx return nil } func personaIDFromPayload(payload map[string]any) string { if id := stringField(payload, "persona_id"); id != "" { return id } return stringField(payload, "scope_id") } func deriveViralKeywords(persona *personadomain.PersonaSummary) []string { if persona == nil { return nil } out := []string{} seen := map[string]struct{}{} add := func(kw string) { kw = strings.TrimSpace(kw) if kw == "" { return } if _, ok := seen[kw]; ok { return } seen[kw] = struct{}{} out = append(out, kw) } for _, tag := range persona.CopyResearchMap.SuggestedTags { add(tag) } for _, q := range persona.CopyResearchMap.Questions { add(q) } if bench := strings.TrimPrefix(strings.TrimSpace(persona.StyleBenchmark), "@"); bench != "" { add(bench) } for _, line := range strings.Split(strings.TrimSpace(persona.Brief), "\n") { line = strings.TrimSpace(line) if line != "" { add(line) break } } return out }