2026-07-10 12:54:45 +00:00
|
|
|
|
package svc
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-13 01:15:30 +00:00
|
|
|
|
"context"
|
2026-07-15 15:23:59 +00:00
|
|
|
|
"errors"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
"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-15 15:23:59 +00:00
|
|
|
|
billingModule "apps/backend/internal/module/billing"
|
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"
|
2026-07-15 15:23:59 +00:00
|
|
|
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
2026-07-10 12:54:45 +00:00
|
|
|
|
"github.com/zeromicro/go-zero/rest"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
type RedisHealth interface {
|
|
|
|
|
|
PingCtx(ctx context.Context) bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:54:45 +00:00
|
|
|
|
// ServiceContext wires goctl middleware + modules.
|
|
|
|
|
|
type ServiceContext struct {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
Config config.Config
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Redis RedisHealth
|
2026-07-13 01:15:30 +00:00
|
|
|
|
AuthJWT rest.Middleware
|
|
|
|
|
|
AdminAuth rest.Middleware
|
2026-07-15 15:23:59 +00:00
|
|
|
|
StripeRawBody rest.Middleware
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Billing *billingModule.Service
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
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)
|
2026-07-10 12:54:45 +00:00
|
|
|
|
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-15 15:23:59 +00:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
modelsCache := ai.NewRedisModelsCache(c.CacheRedis[0].RedisConf, c.Redis.Namespace)
|
|
|
|
|
|
aiRegistry := ai.NewRegistry(modelsCache, c.Redis.AIModelCacheFingerprintSecret)
|
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 只入列 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.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,
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Redis: redisClient,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
Members: repo,
|
|
|
|
|
|
Auth: auth,
|
|
|
|
|
|
Token: issuer,
|
|
|
|
|
|
Notification: notify,
|
|
|
|
|
|
Storage: store,
|
|
|
|
|
|
Threads: threadsSvc,
|
|
|
|
|
|
Jobs: jobs,
|
|
|
|
|
|
AppNotif: appN,
|
|
|
|
|
|
Usage: usageSvc,
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Billing: billingSvc,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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,
|
2026-07-15 15:23:59 +00:00
|
|
|
|
StripeRawBody: middleware.NewStripeRawBodyMiddleware().Handle,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if st == nil {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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_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 {
|
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)
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", "", "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if st == nil {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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)
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if rerr != nil && !errors.Is(rerr, usageDomain.ErrNoKey) {
|
|
|
|
|
|
return provider, model, "", rerr
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|