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

644 lines
22 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"
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"
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
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}
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,
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 := []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
}
// 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.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 {
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
}