thread-master/apps/backend/internal/svc/service_context.go

865 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package svc
import (
"context"
"errors"
"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"
billingModule "apps/backend/internal/module/billing"
fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store"
growthRepo "apps/backend/internal/module/growth/repository"
growthUC "apps/backend/internal/module/growth/usecase"
inspireRepo "apps/backend/internal/module/inspire/repository"
inspireUC "apps/backend/internal/module/inspire/usecase"
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"
radarDomain "apps/backend/internal/module/radar/domain"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
crmRepo "apps/backend/internal/module/crm/repository"
crmUC "apps/backend/internal/module/crm/usecase"
scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase"
"apps/backend/internal/module/search"
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/core/stores/redis"
"github.com/zeromicro/go-zero/rest"
)
type RedisHealth interface {
PingCtx(ctx context.Context) bool
}
// ServiceContext wires goctl middleware + modules.
type ServiceContext struct {
Config config.Config
Redis RedisHealth
AuthJWT rest.Middleware
AdminAuth rest.Middleware
StripeRawBody 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
Billing *billingModule.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
Growth *growthUC.Service
Radar *radarUC.Service
Crm *crmUC.Service
ExtensionZipPath string
}
func NewServiceContext(c config.Config) *ServiceContext {
if len(c.CacheRedis) == 0 {
logx.Must(errString("CacheRedis is required for monc entity cache"))
}
settingsCodec, err := c.NewMemberSettingsCodec()
if err != nil {
logx.Must(err)
}
repo := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis, c.CacheRedis[0].RedisConf, c.Redis.Namespace, settingsCodec)
redisClient := redis.MustNewRedis(c.CacheRedis[0].RedisConf)
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,
}
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60, c.Redis.Namespace)
billingRepo := billingModule.NewMongoRepository(c.Mongo.URI, c.Mongo.Database)
billingSvc := &billingModule.Service{
Repo: billingRepo, Enabled: c.Stripe.Enabled, WebhookSecret: c.Stripe.WebhookSecret,
StarterPriceID: c.Stripe.StarterPriceID, ProPriceID: c.Stripe.ProPriceID,
SuccessURL: c.Stripe.SuccessURL, CancelURL: c.Stripe.CancelURL, PortalReturnURL: c.Stripe.PortalReturnURL,
}
if c.Stripe.Enabled && strings.TrimSpace(c.Stripe.SecretKey) != "" {
billingSvc.Provider = billingModule.NewStripeProvider(c.Stripe.SecretKey)
logx.Info("billing: Stripe subscriptions enabled")
} else {
logx.Info("billing: Stripe disabled")
}
// 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.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
logx.Info("studio: Threads publishing disabled until credentials are configured")
}
modelsCache := ai.NewRedisModelsCache(c.CacheRedis[0].RedisConf, c.Redis.Namespace)
aiRegistry := ai.NewRegistry(modelsCache, c.Redis.AIModelCacheFingerprintSecret)
aiClient, _ := aiRegistry.Client(ai.ProviderXAI)
searchClient := search.NewExa()
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 只入列 jobworker 爬取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.AI = aiClient
scoutSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
scoutSvc.SessionSecret = c.Scout.SessionSecret
scoutSvc.Crawler = scoutUC.NewHTTPCrawlerProvider(c.Scout.CrawlerEndpoint, c.Scout.CrawlerToken)
// 人設對標帳號分析:可用 Chrome 同步的 crawler session 讀公開頁
studioSvc.Crawler = scoutSvc
// 靈感 chat 注入真人人設 + 品牌產品目錄
inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc}
inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc}
growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
growthSvc.Notifier = &growthNotifBridge{App: appN}
growthSvc.Bonus = usageSvc
// Scout mark-published → outcome hook
scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) {
_, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0)
}
// Outbox step published → outcome
studioSvc.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) {
_, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
}
radarSvc := radarUC.New(radarRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
radarSvc.Quota = &radarQuotaBridge{Usage: usageSvc}
radarSvc.Usage = usageSvc
radarSvc.AI = aiClient
radarSvc.AIRegistry = aiRegistry
radarSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) {
return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid)
}
radarSvc.ResolveKey = func(ctx context.Context, uid int64, meter string) (string, string, error) {
return keyRes.ResolveKey(ctx, uid, meter)
}
// 關鍵字建議拿既有海巡痛點詞當素材,不另建第二套關鍵字引擎。
radarSvc.PainTerms = &radarPainTermBridge{Scout: scoutSvc}
crmSvc := crmUC.New(crmRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
crmSvc.Growth = &crmGrowthBridge{Growth: growthSvc}
crmSvc.RadarOpps = &crmRadarOppBridge{Radar: radarSvc}
crmSvc.Notifier = &crmFollowUpNotifBridge{App: appN}
radarSvc.CRM = crmSvc
radarSvc.HitFetch = &scoutHitAdapter{Scout: scoutSvc}
radarSvc.Notifier = radarUC.NotifierFromAppNotif(&radarSystemNotif{App: appN})
radarSvc.Health = &radarHealthBridge{Growth: growthSvc}
// 每日巡與手動觸發共用 job.ScheduleRadarSweep同 template、同日去重
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
})
zipPath := findExtensionZip()
return &ServiceContext{
Config: c,
Redis: redisClient,
Members: repo,
Auth: auth,
Token: issuer,
Notification: notify,
Storage: store,
Threads: threadsSvc,
Jobs: jobs,
AppNotif: appN,
Usage: usageSvc,
Billing: billingSvc,
KeyResolver: keyRes,
AI: aiClient,
AIRegistry: aiRegistry,
Search: searchClient,
Studio: studioSvc,
PublishTransport: pub,
Inspire: inspireSvc,
Scout: scoutSvc,
Growth: growthSvc,
Radar: radarSvc,
Crm: crmSvc,
ExtensionZipPath: zipPath,
AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle,
AdminAuth: middleware.NewAdminAuthMiddleware().Handle,
StripeRawBody: middleware.NewStripeRawBodyMiddleware().Handle,
}
}
// devModeFromMembers adapts member settings for scout path selection.
type devModeFromMembers struct {
Members memberDomain.Repository
}
type scoutReplyQueue struct{ Studio *studioUC.Service }
func (q *scoutReplyQueue) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (string, error) {
bundle, err := q.Studio.QueueExternalReply(ctx, ownerUID, accountID, replyToMediaID, text, title)
if err != nil {
return "", err
}
return bundle.ID, nil
}
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 {
return false, err
}
if st == nil {
return false, nil
}
return st.DevModeEnabled, nil
}
func findExtensionZip() string {
cands := make([]string, 0, 5)
if executable, err := os.Executable(); err == nil {
cands = append(cands, filepath.Join(filepath.Dir(executable), "..", "web", "downloads", "haixun-threads-sync.zip"))
}
cands = append(cands,
"../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
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_urierror 1349187
// 優先 PublicAPIBase → 與 PublicWebBase 同 hostnginx 反代 /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 {
provider = threadsProv.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
logx.Info("threads oauth disabled until credentials are configured")
}
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.svg"
}
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 {
return "", "", "", err
}
if 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 && !errors.Is(rerr, usageDomain.ErrNoKey) {
return provider, model, "", rerr
}
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
}
/*
radarQuotaBridge 讓 radar module 讀既有方案定義,而不必反過來依賴 usage module。
雷達上限跟著方案走,但不經 meterAI 判定與回覆仍計既有四個 meter
這裡只回「可以有幾個常駐訂閱、一天最多收幾筆商機」。
*/
type radarQuotaBridge struct {
Usage *usageUC.Service
}
func (b *radarQuotaBridge) RadarQuota(ctx context.Context, ownerUID int64) (int, int, error) {
if b == nil || b.Usage == nil {
return 0, 0, errString("usage service unavailable for radar quota")
}
plan, err := b.Usage.PlanFor(ctx, ownerUID)
if err != nil {
return 0, 0, err
}
return plan.MaxActiveWatches, plan.MaxDailyOpportunities, nil
}
/*
radarPainTermBridge 把既有海巡產品的痛點關鍵字餵給雷達的關鍵字建議當素材。
只讀不寫,也不影響海巡行為;沒有品牌/產品的人就是拿不到素材,建議品質下降但功能照跑。
*/
type radarPainTermBridge struct {
Scout *scoutUC.Service
}
func (b *radarPainTermBridge) PainTerms(ctx context.Context, ownerUID int64) ([]string, error) {
if b == nil || b.Scout == nil {
return nil, nil
}
products, err := b.Scout.ListAllProducts(ctx, ownerUID)
if err != nil {
return nil, err
}
out := make([]string, 0, 16)
seen := map[string]bool{}
for _, p := range products {
for _, pain := range p.PainPoints {
pain = strings.TrimSpace(pain)
if pain == "" || seen[pain] {
continue
}
seen[pain] = true
out = append(out, pain)
// prompt 素材夠用就好,塞太多會擠掉服務檔案本身的資訊。
if len(out) >= 30 {
return out, nil
}
}
}
return out, nil
}
type growthNotifBridge struct {
App *appnotifUC.Service
}
func (b *growthNotifBridge) Notify(ctx context.Context, ownerUID int64, title, body, kind, refType, refID string) error {
if b == nil || b.App == nil {
return nil
}
// Reuse job-state channel for system notes via NotifyJobState with synthetic id.
return b.App.NotifyJobState(ctx, ownerUID, "growth:"+refType+":"+refID, "growth", "succeeded", title+": "+body, 100)
}
type radarSystemNotif struct{ App *appnotifUC.Service }
func (b *radarSystemNotif) InsertSystem(ctx context.Context, ownerUID int64, title, body, refType, refID string) error {
if b == nil || b.App == nil {
return nil
}
return b.App.NotifyJobState(ctx, ownerUID, refType+":"+refID, "radar", "failed", title+": "+body, 0)
}
type crmFollowUpNotifBridge struct{ App *appnotifUC.Service }
func (b *crmFollowUpNotifBridge) NotifyFollowUp(ctx context.Context, ownerUID int64, contactID, followUpID string) error {
if b == nil || b.App == nil {
return nil
}
return b.App.NotifyJobState(ctx, ownerUID, "followup:"+followUpID, "radar_followup", "succeeded",
"待追蹤到期:請回訪聯絡人", 100)
}
type crmRadarOppBridge struct{ Radar *radarUC.Service }
func (b *crmRadarOppBridge) GetOpportunity(ctx context.Context, id string) (*radarDomain.Opportunity, error) {
if b == nil || b.Radar == nil {
return nil, radarDomain.ErrNotFound
}
return b.Radar.Repo.GetOpportunity(ctx, id)
}
type radarHealthBridge struct{ Growth *growthUC.Service }
func (b *radarHealthBridge) WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error) {
if b == nil || b.Growth == nil {
return "ok", "", nil
}
list, err := b.Growth.ListHealth(ctx, ownerUID)
if err != nil {
return "", "", err
}
level = "ok"
for _, h := range list {
if h == nil {
continue
}
switch h.Level {
case "throttle":
return "throttle", h.Advice, nil
case "warn":
level = "warn"
if advice == "" {
advice = h.Advice
}
}
}
return level, advice, nil
}
type crmGrowthBridge struct{ Growth *growthUC.Service }
func (b *crmGrowthBridge) RecordConversion(ctx context.Context, ownerUID int64, contactID string, amount float64, currency, note string) (string, error) {
e, err := b.Growth.RecordPublished(ctx, ownerUID, "radar_opportunity", contactID, "", 0)
if err != nil {
return "", err
}
out, err := b.Growth.ReportConversion(ctx, ownerUID, e.ID, amount, note, currency)
if err != nil {
return e.ID, err
}
return out.ID, nil
}
func (b *crmGrowthBridge) AmendConversion(ctx context.Context, ownerUID int64, outcomeID string, amount float64, currency, note string) error {
_, err := b.Growth.UpdateConversion(ctx, ownerUID, outcomeID, amount, note, currency)
return err
}
type scoutHitAdapter struct{ Scout *scoutUC.Service }
func (a *scoutHitAdapter) SearchHits(ctx context.Context, ownerUID int64, terms []string, limit int) ([]radarUC.ThreadHit, string, error) {
if a == nil || a.Scout == nil {
return nil, "", fmt.Errorf("scout not configured")
}
hits, path, err := a.Scout.SearchHitsOnly(ctx, ownerUID, terms, limit)
if err != nil {
return nil, path, err
}
out := make([]radarUC.ThreadHit, 0, len(hits))
for _, h := range hits {
out = append(out, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet})
}
return out, path, nil
}