612 lines
21 KiB
Go
612 lines
21 KiB
Go
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
|
||
}
|
||
|