package svc import ( "context" "fmt" "strings" "os" "path/filepath" "apps/backend/internal/config" "apps/backend/internal/middleware" "apps/backend/internal/module/ai" appnotifRepo "apps/backend/internal/module/appnotif/repository" appnotifUC "apps/backend/internal/module/appnotif/usecase" fsDomain "apps/backend/internal/module/filestorage/domain" "apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/s3store" jobRepo "apps/backend/internal/module/job/repository" jobUC "apps/backend/internal/module/job/usecase" memberDomain "apps/backend/internal/module/member/domain" memberRepo "apps/backend/internal/module/member/repository" memberUC "apps/backend/internal/module/member/usecase" notifDomain "apps/backend/internal/module/notification/domain" notifUC "apps/backend/internal/module/notification/usecase" "apps/backend/internal/module/search" inspireRepo "apps/backend/internal/module/inspire/repository" inspireUC "apps/backend/internal/module/inspire/usecase" scoutRepo "apps/backend/internal/module/scout/repository" scoutUC "apps/backend/internal/module/scout/usecase" studioDomain "apps/backend/internal/module/studio/domain" studioPublish "apps/backend/internal/module/studio/publish" studioRepo "apps/backend/internal/module/studio/repository" studioUC "apps/backend/internal/module/studio/usecase" threadsDomain "apps/backend/internal/module/threads/domain" threadsProv "apps/backend/internal/module/threads/provider" threadsRepo "apps/backend/internal/module/threads/repository" threadsUC "apps/backend/internal/module/threads/usecase" tokenUC "apps/backend/internal/module/token/usecase" usageDomain "apps/backend/internal/module/usage/domain" usageRepo "apps/backend/internal/module/usage/repository" usageUC "apps/backend/internal/module/usage/usecase" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/rest" ) // ServiceContext wires goctl middleware + modules. type ServiceContext struct { Config config.Config AuthJWT rest.Middleware AdminAuth rest.Middleware Members memberDomain.Repository Auth *memberUC.AuthService Token *tokenUC.JWTIssuer Notification notifDomain.UseCase // email Storage fsDomain.Storage Threads *threadsUC.Service Jobs *jobUC.Service AppNotif *appnotifUC.Service Usage *usageUC.Service KeyResolver *usageUC.SettingsResolver AI ai.Client AIRegistry *ai.Registry Search search.Client Studio *studioUC.Service PublishTransport studioPublish.Transport Inspire *inspireUC.Service Scout *scoutUC.Service ExtensionZipPath string } func NewServiceContext(c config.Config) *ServiceContext { if len(c.CacheRedis) == 0 { logx.Must(errString("CacheRedis is required for monc entity cache")) } repo := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis) logx.Infof("mongo monc ready db=%s redis_cache=%s", c.Mongo.Database, c.CacheRedis[0].Host) issuer := tokenUC.NewJWTIssuer( c.Auth.AccessSecret, c.Auth.RefreshSecret, c.Auth.AccessExpire, c.Auth.RefreshExpire, ) cost := c.Bcrypt.Cost if cost <= 0 { cost = 10 } auth := memberUC.NewAccountService(repo, issuer, cost) auth.GoogleClientID = c.OAuth.GoogleClientID auth.LineClientID = c.OAuth.LineClientID auth.LineClientSecret = c.OAuth.LineClientSecret auth.LineRedirectURI = c.OAuth.LineRedirectURI notify := newNotification(c) store := newStorage(c) appN := appnotifUC.New(appnotifRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) jobs.Notifier = appN threadsSvc := newThreads(c) // 連帳成功 → 排程 ~30 天後 token renew job threadsSvc.Renew = jobs keyRes := &usageUC.SettingsResolver{ Members: repo, PlatformAI: c.Platform.AIKey, PlatformXAI: c.Platform.XAIKey, PlatformOpenCode: c.Platform.OpenCodeKey, PlatformExa: c.Platform.ExaKey, } // Dev: empty platform keys still allow PrepareCall for quota path; real LLM needs real key if keyRes.PlatformAI == "" && keyRes.PlatformXAI == "" && keyRes.PlatformOpenCode == "" { keyRes.PlatformAI = "fake-platform-ai" logx.Info("usage: no platform AI keys — using fake-platform-ai for quota only (persona LLM needs real key)") } if keyRes.PlatformExa == "" { keyRes.PlatformExa = "fake-platform-exa" logx.Info("usage: Platform.ExaKey empty — using fake-platform-exa for demo/proxy") } usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes) // M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步) var pub studioPublish.Transport var mediaSource studioUC.ThreadsMediaSource if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" { pub = studioPublish.NewMeta("https://graph.threads.net") mediaSource = &metaMediaBridge{Meta: threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)} logx.Info("studio: meta publish + own-posts sync enabled") } else { pub = studioPublish.NewFake() logx.Info("studio: fake publish transport (no Threads app credentials)") } aiRegistry := ai.NewRegistry() aiClient := &ai.FakeClient{} // tests / synthetic keys only var searchClient search.Client if strings.TrimSpace(c.Platform.ExaKey) != "" { searchClient = search.NewExa() logx.Info("search: real Exa client") } else { searchClient = &search.FakeClient{} logx.Info("search: FakeClient (no Platform.ExaKey)") } studioSvc := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub) studioSvc.Accounts = threadsSvc // *threadsUC.Service implements AccountLookup studioSvc.Usage = usageSvc studioSvc.AI = aiClient studioSvc.AIRegistry = aiRegistry studioSvc.Keys = &studioAIKeys{Members: repo, Resolver: keyRes} studioSvc.Media = mediaSource studioSvc.Storage = store studioSvc.StoragePublicBase = strings.TrimSpace(c.ObjectStorage.PublicBaseURL) // 人設分析:API 只入列 job,worker 爬取/LLM 後存檔(離開頁面不中斷) studioSvc.Jobs = &personaJobBridge{Jobs: jobs} // M5 inspire + scout(真 AI + stream) inspireSvc := inspireUC.New(inspireRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) inspireSvc.Usage = usageSvc inspireSvc.AI = aiClient inspireSvc.AIRegistry = aiRegistry inspireSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) { return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid) } inspireSvc.Search = searchClient inspireSvc.ResolveKey = func(ctx context.Context, uid int64, meter string) (string, string, error) { return keyRes.ResolveKey(ctx, uid, meter) } scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) scoutSvc.Settings = &devModeFromMembers{Members: repo} scoutSvc.Transport = pub scoutSvc.Accounts = threadsSvc scoutSvc.AI = aiClient // 人設對標帳號分析:可用 Chrome 同步的 crawler session 讀公開頁 studioSvc.Crawler = scoutSvc // 靈感 chat 注入真人人設 + 品牌產品目錄 inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc} inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc} zipPath := findExtensionZip() return &ServiceContext{ Config: c, Members: repo, Auth: auth, Token: issuer, Notification: notify, Storage: store, Threads: threadsSvc, Jobs: jobs, AppNotif: appN, Usage: usageSvc, KeyResolver: keyRes, AI: aiClient, AIRegistry: aiRegistry, Search: searchClient, Studio: studioSvc, PublishTransport: pub, Inspire: inspireSvc, Scout: scoutSvc, ExtensionZipPath: zipPath, AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle, AdminAuth: middleware.NewAdminAuthMiddleware().Handle, } } // devModeFromMembers adapts member settings for scout path selection. type devModeFromMembers struct { Members memberDomain.Repository } func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (bool, error) { if d == nil || d.Members == nil { return false, nil } st, err := d.Members.GetSettings(ctx, uid) if err != nil || st == nil { return false, nil } return st.DevModeEnabled, nil } func findExtensionZip() string { cands := []string{ "../web/public/downloads/haixun-threads-sync.zip", "../../apps/web/public/downloads/haixun-threads-sync.zip", "apps/web/public/downloads/haixun-threads-sync.zip", "/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip", } for _, c := range cands { if st, err := os.Stat(c); err == nil && st.Size() > 0 { abs, _ := filepath.Abs(c) logx.Infof("extension zip: %s (%d bytes)", abs, st.Size()) return abs } } logx.Error("extension zip not found — ST-16 download will 404") return "" } func newThreads(c config.Config) *threadsUC.Service { repo := threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database) secret := c.Auth.AccessSecret if secret == "" { secret = "dev-token-secret" } web := strings.TrimRight(c.PublicWebBase, "/") if web == "" { web = "http://127.0.0.1:5173" } port := c.Port if port == 0 { port = 8888 } // Meta 拒絕 http://127.0.0.1 redirect_uri(error 1349187)。 // 優先 PublicAPIBase → 與 PublicWebBase 同 host(nginx 反代 /api)→ 本機 loopback。 apiBase := strings.TrimRight(c.PublicAPIBase, "/") if apiBase == "" && strings.HasPrefix(web, "https://") { apiBase = web } if apiBase == "" { apiBase = fmt.Sprintf("http://127.0.0.1:%d", port) } var provider threadsDomain.Provider if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" { provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret) logx.Infof("threads oauth: meta provider callback=%s/api/v1/threads-accounts/oauth/callback", apiBase) if strings.HasPrefix(apiBase, "http://") { logx.Error("threads oauth: callback is HTTP — Meta will block (use https PublicAPIBase / PublicWebBase)") } } else { cb := apiBase + "/api/v1/threads-accounts/oauth/callback" provider = &threadsProv.FakeProvider{CallbackBase: cb} logx.Info("threads oauth: fake provider (set Platform.ThreadsAppId/Secret for Meta)") } svc := threadsUC.New(repo, provider, secret) svc.APIPublicBase = apiBase svc.WebBase = web svc.CallbackPath = "/api/v1/threads-accounts/oauth/callback" return svc } func newStorage(c config.Config) fsDomain.Storage { os := c.ObjectStorage if strings.TrimSpace(os.Endpoint) == "" { logx.Error("object storage: Endpoint empty — uploads disabled (MinIO/S3 required)") return noop.New() } s, err := s3store.New(s3store.Config{ Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket, AccessKey: os.AccessKey, SecretKey: os.SecretKey, UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL, }) if err != nil { logx.Errorf("object storage MinIO/S3 init failed: %v — uploads disabled", err) return noop.New() } logx.Infof("object storage: minio/s3 endpoint=%s bucket=%s", os.Endpoint, os.Bucket) return s } func newNotification(c config.Config) notifDomain.UseCase { base := strings.TrimRight(c.PublicWebBase, "/") if base == "" { base = "http://127.0.0.1:5173" } brand := notifDomain.Brand{ Name: c.Brand.Name, Link: base, LogoURL: c.Brand.LogoURL, Copyright: c.Brand.Copyright, } if brand.Name == "" { brand.Name = "Harbor Desk" } if brand.LogoURL == "" { brand.LogoURL = base + "/brand-mark.jpg" } if brand.Copyright == "" { brand.Copyright = "© Harbor Desk" } cfg := notifUC.Config{ Sender: c.Mail.Sender, DevExposeCode: c.Mail.DevExposeCode, Brand: brand, } if c.Mail.SMTP.Host != "" { cfg.Providers = []notifDomain.EmailDelivery{ notifUC.NewSMTPDelivery(notifUC.SMTPConfig{ Host: c.Mail.SMTP.Host, Port: c.Mail.SMTP.Port, User: c.Mail.SMTP.User, Password: c.Mail.SMTP.Password, }), } logx.Infof("notification mail: smtp host=%s port=%d logo=%s expose_code=%v", c.Mail.SMTP.Host, c.Mail.SMTP.Port, brand.LogoURL, c.Mail.DevExposeCode) } else { cfg.Providers = []notifDomain.EmailDelivery{notifUC.NewLogDelivery()} logx.Info("notification mail: log-only (set Mail.SMTP.Host to send real email)") } return notifUC.NewService(cfg) } type errString string func (e errString) Error() string { return string(e) } // metaMediaBridge adapts threads MetaProvider → studio.ThreadsMediaSource type metaMediaBridge struct { Meta *threadsProv.MetaProvider } func (b *metaMediaBridge) ListThreads(ctx context.Context, accessToken string, limit int) ([]studioUC.FetchedThread, error) { list, err := b.Meta.ListThreads(ctx, accessToken, limit) if err != nil { return nil, err } out := make([]studioUC.FetchedThread, 0, len(list)) for _, t := range list { pub := int64(0) if !t.Timestamp.IsZero() { pub = t.Timestamp.UnixNano() } out = append(out, studioUC.FetchedThread{ ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL, ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode, TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub, }) } return out, nil } func (b *metaMediaBridge) GetInsights(ctx context.Context, accessToken, mediaID string) (studioUC.FetchedInsights, error) { ins, err := b.Meta.GetInsights(ctx, accessToken, mediaID) if err != nil { return studioUC.FetchedInsights{Status: "error", ErrorMsg: err.Error()}, nil } return studioUC.FetchedInsights{ Views: ins.Views, Likes: ins.Likes, Replies: ins.Replies, Reposts: ins.Reposts, Quotes: ins.Quotes, Shares: ins.Shares, Status: ins.Status, ErrorMsg: ins.ErrorMsg, }, nil } func (b *metaMediaBridge) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]studioUC.FetchedReply, error) { list, err := b.Meta.ListConversation(ctx, accessToken, mediaID, limit) if err != nil { return nil, nil } out := make([]studioUC.FetchedReply, 0, len(list)) for _, r := range list { pub := int64(0) if !r.Timestamp.IsZero() { pub = r.Timestamp.UnixNano() } out = append(out, studioUC.FetchedReply{ ID: r.ID, Text: r.Text, Username: r.Username, ParentMediaID: r.ParentMediaID, PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount, }) } return out, nil } func (b *metaMediaBridge) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]studioUC.FetchedMention, error) { list, err := b.Meta.ListMentions(ctx, accessToken, threadsUserID, limit) if err != nil { return nil, err } out := make([]studioUC.FetchedMention, 0, len(list)) for _, m := range list { pub := int64(0) if !m.Timestamp.IsZero() { pub = m.Timestamp.UnixNano() } out = append(out, studioUC.FetchedMention{ ID: m.ID, Text: m.Text, Username: m.Username, Permalink: m.Permalink, RootPostID: m.RootPostID, ParentID: m.ParentID, IsReply: m.IsReply, IsQuotePost: m.IsQuotePost, PublishedAt: pub, }) } return out, nil } func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]studioUC.FetchedThread, error) { list, err := b.Meta.ListProfilePosts(ctx, accessToken, username, limit) if err != nil { return nil, err } out := make([]studioUC.FetchedThread, 0, len(list)) for _, t := range list { pub := int64(0) if !t.Timestamp.IsZero() { pub = t.Timestamp.UnixNano() } out = append(out, studioUC.FetchedThread{ ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL, ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode, TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub, }) } return out, nil } // personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only). type personaJobBridge struct { Jobs *jobUC.Service } func (b *personaJobBridge) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (string, error) { if b == nil || b.Jobs == nil { return "", fmt.Errorf("jobs not configured") } j, err := b.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, personaID, username, lang) if err != nil { return "", err } return j.ID, nil } func (b *personaJobBridge) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (string, error) { if b == nil || b.Jobs == nil { return "", fmt.Errorf("jobs not configured") } j, err := b.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, personaID, rawText, sourceLabel, lang) if err != nil { return "", err } return j.ID, nil } // studioAIKeys adapts member settings + usage key resolver for persona LLM. type studioAIKeys struct { Members memberDomain.Repository Resolver *usageUC.SettingsResolver } func (k *studioAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) { st, err := k.Members.GetSettings(ctx, ownerUID) if err != nil || st == nil { st = memberDomain.DefaultSettings(ownerUID) } // 完全依會員 AI 設定,不寫死/不替換模型 id provider = ai.NormalizeProvider(st.Provider) model = strings.TrimSpace(st.Model) if model == "" { return provider, "", "", fmt.Errorf("請到設定選擇 AI 模型") } if k.Resolver != nil { _, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy) if rerr == nil && strings.TrimSpace(key) != "" { return provider, model, key, nil } } // BYOK direct if key := st.KeyForProvider(provider); key != "" { return provider, model, key, nil } return provider, model, "", fmt.Errorf("no AI key(請到設定填寫 Key,或確認平台已配置)") } // inspirePersonaBridge — studio 人設 → 靈感 prompt 完整快照 type inspirePersonaBridge struct { Studio *studioUC.Service } func (b *inspirePersonaBridge) ResolvePersona(ctx context.Context, ownerUID int64, personaID string) (*inspireUC.PersonaSnapshot, error) { if b == nil || b.Studio == nil { return nil, nil } if strings.TrimSpace(personaID) != "" { if got, err := b.Studio.GetPersona(ctx, ownerUID, personaID); err == nil && got != nil { return studioPersonaToSnapshot(got), nil } } if aid, err := b.Studio.GetActivePersonaID(ctx, ownerUID); err == nil && aid != "" { if got, gerr := b.Studio.GetPersona(ctx, ownerUID, aid); gerr == nil && got != nil { return studioPersonaToSnapshot(got), nil } } list, err := b.Studio.ListPersonas(ctx, ownerUID) if err != nil || len(list) == 0 { return nil, nil } return studioPersonaToSnapshot(list[0]), nil } func studioPersonaToSnapshot(p *studioDomain.Persona) *inspireUC.PersonaSnapshot { if p == nil { return nil } fp := strings.TrimSpace(p.Style.DraftText) if fp == "" { fp = serializePersonaDraft(p.Style.Draft) } dims := map[string]string{} for k, d := range p.Style.Dimensions { if sm := strings.TrimSpace(d.Summary); sm != "" { dims[k] = sm } } avoid := append([]string{}, p.Guard.Avoid...) return &inspireUC.PersonaSnapshot{ ID: p.ID, Name: p.Name, Brief: p.Brief, Status: p.Status, DraftText: fp, Voice: p.Voice, GuardAvoid: avoid, MaxChars: p.Guard.MaxChars, BanAiTone: p.Guard.BanAiTone, DimSummaries: dims, } } func serializePersonaDraft(d studioDomain.PersonaDraft) string { type pair struct{ label, value string } parts := []pair{ {"我是誰", d.Identity}, {"語氣", d.Tone}, {"對誰說", d.Audience}, {"開場鉤子", d.Hooks}, {"語言指紋", d.LanguageFingerprint}, {"節奏", d.Rhythm}, {"標點", d.Punctuation}, {"內容套路", d.ContentPatterns}, {"知識轉譯", d.KnowledgeTranslation}, {"CTA", d.CtaStyle}, {"像他會說的話", d.Examples}, {"絕不怎麼說", d.Avoid}, } var lines []string for _, p := range parts { v := strings.TrimSpace(p.value) if v == "" { continue } lines = append(lines, "【"+p.label+"】\n"+v) } return strings.Join(lines, "\n\n") } // inspireBrandBridge — scout 品牌/產品目錄 type inspireBrandBridge struct { Scout *scoutUC.Service } func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([]inspireUC.BrandSnapshot, error) { if b == nil || b.Scout == nil { return nil, nil } brands, err := b.Scout.ListBrands(ctx, ownerUID) if err != nil { return nil, err } out := make([]inspireUC.BrandSnapshot, 0, len(brands)) for _, br := range brands { if br == nil { continue } snap := inspireUC.BrandSnapshot{ ID: br.ID, DisplayName: br.DisplayName, Brief: br.Brief, Audience: br.TargetAudience, Goals: br.Goals, } prods, _ := b.Scout.ListProducts(ctx, ownerUID, br.ID) for _, pr := range prods { if pr == nil { continue } snap.Products = append(snap.Products, inspireUC.ProductSnapshot{ ID: pr.ID, Label: pr.Label, Context: pr.ProductContext, Tags: append([]string{}, pr.MatchTags...), Pains: append([]string{}, pr.PainPoints...), }) } out = append(out, snap) } return out, nil }