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

604 lines
21 KiB
Go
Raw Normal View History

2026-07-10 12:54:45 +00:00
package svc
import (
2026-07-13 01:15:30 +00:00
"context"
"fmt"
2026-07-10 12:54:45 +00:00
"strings"
2026-07-13 01:15:30 +00:00
"os"
"path/filepath"
2026-07-10 12:54:45 +00:00
"apps/backend/internal/config"
"apps/backend/internal/middleware"
2026-07-13 01:15:30 +00:00
"apps/backend/internal/module/ai"
appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase"
2026-07-10 12:54:45 +00:00
fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store"
2026-07-13 08:59:13 +00:00
inspireRepo "apps/backend/internal/module/inspire/repository"
inspireUC "apps/backend/internal/module/inspire/usecase"
2026-07-13 01:15:30 +00:00
jobRepo "apps/backend/internal/module/job/repository"
jobUC "apps/backend/internal/module/job/usecase"
2026-07-10 12:54:45 +00:00
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"
2026-07-13 01:15:30 +00:00
scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase"
2026-07-13 08:59:13 +00:00
"apps/backend/internal/module/search"
2026-07-13 01:15:30 +00:00
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"
2026-07-10 12:54:45 +00:00
tokenUC "apps/backend/internal/module/token/usecase"
2026-07-13 01:15:30 +00:00
usageDomain "apps/backend/internal/module/usage/domain"
usageRepo "apps/backend/internal/module/usage/repository"
usageUC "apps/backend/internal/module/usage/usecase"
2026-07-10 12:54:45 +00:00
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/rest"
)
// ServiceContext wires goctl middleware + modules.
type ServiceContext struct {
2026-07-13 01:15:30 +00:00
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
2026-07-10 12:54:45 +00:00
}
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)
2026-07-13 01:15:30 +00:00
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)
2026-07-13 08:59:13 +00:00
usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60)
2026-07-13 01:15:30 +00:00
// 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 {
2026-07-13 08:59:13 +00:00
pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
logx.Info("studio: Threads publishing disabled until credentials are configured")
2026-07-13 01:15:30 +00:00
}
aiRegistry := ai.NewRegistry()
2026-07-13 08:59:13 +00:00
aiClient, _ := aiRegistry.Client(ai.ProviderXAI)
searchClient := search.NewExa()
2026-07-13 01:15:30 +00:00
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
2026-07-13 08:59:13 +00:00
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)
2026-07-13 01:15:30 +00:00
// 人設對標帳號分析:可用 Chrome 同步的 crawler session 讀公開頁
studioSvc.Crawler = scoutSvc
// 靈感 chat 注入真人人設 + 品牌產品目錄
inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc}
inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc}
zipPath := findExtensionZip()
2026-07-10 12:54:45 +00:00
return &ServiceContext{
2026-07-13 01:15:30 +00:00
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
}
2026-07-13 08:59:13 +00:00
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
}
2026-07-13 01:15:30 +00:00
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
web := strings.TrimRight(c.PublicWebBase, "/")
if web == "" {
web = "http://127.0.0.1:5173"
}
port := c.Port
if port == 0 {
port = 8888
2026-07-10 12:54:45 +00:00
}
2026-07-13 01:15:30 +00:00
// 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 {
2026-07-13 08:59:13 +00:00
provider = threadsProv.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
logx.Info("threads oauth disabled until credentials are configured")
2026-07-13 01:15:30 +00:00
}
svc := threadsUC.New(repo, provider, secret)
svc.APIPublicBase = apiBase
svc.WebBase = web
svc.CallbackPath = "/api/v1/threads-accounts/oauth/callback"
return svc
2026-07-10 12:54:45 +00:00
}
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{
2026-07-13 01:15:30 +00:00
Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket,
AccessKey: os.AccessKey, SecretKey: os.SecretKey,
UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL,
2026-07-10 12:54:45 +00:00
})
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{
2026-07-13 01:15:30 +00:00
Name: c.Brand.Name, Link: base, LogoURL: c.Brand.LogoURL, Copyright: c.Brand.Copyright,
2026-07-10 12:54:45 +00:00
}
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{
2026-07-13 01:15:30 +00:00
Host: c.Mail.SMTP.Host, Port: c.Mail.SMTP.Port,
User: c.Mail.SMTP.User, Password: c.Mail.SMTP.Password,
2026-07-10 12:54:45 +00:00
}),
}
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) }
2026-07-13 01:15:30 +00:00
// 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,
2026-07-13 08:59:13 +00:00
Tags: append([]string{}, pr.MatchTags...),
2026-07-13 01:15:30 +00:00
Pains: append([]string{}, pr.PainPoints...),
})
}
out = append(out, snap)
}
return out, nil
}