package usecase import ( "context" "encoding/json" "errors" "strings" "haixun-backend/internal/library/clock" "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/placement" memberdomain "haixun-backend/internal/model/member/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" settingdomain "haixun-backend/internal/model/setting/domain/usecase" "haixun-backend/internal/model/threads_account/domain/entity" domrepo "haixun-backend/internal/model/threads_account/domain/repository" domusecase "haixun-backend/internal/model/threads_account/domain/usecase" "github.com/google/uuid" ) const ( settingScopeUser = "user" settingScopeAccount = "account" keyConnectionPrefs = "connection.prefs" defaultSearchSourceMode = "mixed" defaultRepliesPerPost = 10 ) type threadsAccountUseCase struct { repo domrepo.Repository secretsRepo domrepo.SecretsRepository members memberdomain.UseCase settings settingdomain.UseCase personas personadomain.UseCase cipher *crypto.Cipher } func NewUseCase( repo domrepo.Repository, secretsRepo domrepo.SecretsRepository, members memberdomain.UseCase, settings settingdomain.UseCase, personas personadomain.UseCase, cipher *crypto.Cipher, ) domusecase.UseCase { return &threadsAccountUseCase{ repo: repo, secretsRepo: secretsRepo, members: members, settings: settings, personas: personas, cipher: cipher, } } func (u *threadsAccountUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID) if err != nil { return nil, err } member, err := u.members.GetByUID(ctx, tenantID, ownerUID) if err != nil { return nil, err } activeID := member.ActiveThreadsAccountID if activeID == "" && len(items) > 0 { activeID = items[0].ID } list := make([]domusecase.AccountSummary, 0, len(items)) for _, item := range items { summary, err := u.toSummary(ctx, item) if err != nil { return nil, err } list = append(list, *summary) } return &domusecase.ListResult{List: list, ActiveAccountID: activeID}, nil } func (u *threadsAccountUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.AccountSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID); err != nil { return nil, err } displayName := strings.TrimSpace(req.DisplayName) if displayName == "" { existing, err := u.repo.ListByOwner(ctx, req.TenantID, req.OwnerUID) if err != nil { return nil, err } displayName = "帳號 " + itoa(len(existing)+1) } account, err := u.repo.Create(ctx, &entity.Account{ ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, DisplayName: displayName, Status: entity.StatusOpen, }) if err != nil { return nil, err } if req.Activate { if err := u.members.SetActiveThreadsAccountID(ctx, req.TenantID, req.OwnerUID, account.ID); err != nil { return nil, err } } return u.toSummary(ctx, account) } func (u *threadsAccountUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AccountSummary, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } return u.toSummary(ctx, account) } func (u *threadsAccountUseCase) Update(ctx context.Context, req domusecase.UpdateAccountRequest) (*domusecase.AccountSummary, error) { if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil { return nil, err } var personaID *string if req.PersonaID != nil { trimmed := strings.TrimSpace(*req.PersonaID) if trimmed == "" { empty := "" personaID = &empty } else { if _, err := u.personas.Get(ctx, req.TenantID, req.OwnerUID, trimmed); err != nil { return nil, err } personaID = &trimmed } } account, err := u.repo.UpdateShell(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.DisplayName, nil, personaID) if err != nil { return nil, err } return u.toSummary(ctx, account) } func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID, accountID string) error { if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil { return err } return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID) } func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } prefs, err := u.loadConnectionPrefs(ctx, accountID) if err != nil { return nil, err } browserConnected, apiConnected, err := u.connectionFlags(ctx, accountID) if err != nil { return nil, err } return &domusecase.ConnectionData{ AccountID: account.ID, AccountName: accountLabel(account), Username: account.Username, BrowserConnected: browserConnected, ApiConnected: apiConnected, Prefs: prefs, }, nil } func (u *threadsAccountUseCase) UpdateConnection(ctx context.Context, req domusecase.UpdateConnectionRequest) (*domusecase.ConnectionData, error) { account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID) if err != nil { return nil, err } current, err := u.loadConnectionPrefs(ctx, req.AccountID) if err != nil { return nil, err } next := applyConnectionPatch(current, req.Prefs) if err := u.saveConnectionPrefs(ctx, req.AccountID, next); err != nil { return nil, err } browserConnected, apiConnected, err := u.connectionFlags(ctx, req.AccountID) if err != nil { return nil, err } return &domusecase.ConnectionData{ AccountID: account.ID, AccountName: accountLabel(account), Username: account.Username, BrowserConnected: browserConnected, ApiConnected: apiConnected, Prefs: next, }, nil } func (u *threadsAccountUseCase) ImportBrowserSession(ctx context.Context, req domusecase.ImportBrowserSessionRequest) (*domusecase.ImportBrowserSessionResult, error) { account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID) if err != nil { return nil, err } normalized, err := normalizeStorageState(req.StorageState) if err != nil { return nil, err } secrets, err := u.secretsRepo.SaveBrowserStorageState(ctx, account.ID, normalized) if err != nil { return nil, err } message := "Chrome session 已同步到開發模式爬蟲" if account.Username != "" { message = "Chrome session 已同步:@" + account.Username } return &domusecase.ImportBrowserSessionResult{ AccountID: account.ID, Username: account.Username, Synced: true, Valid: true, Message: message, UpdateAt: secrets.UpdateAt, }, nil } func (u *threadsAccountUseCase) GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.BrowserSessionData, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID) if err != nil { return nil, err } if secrets == nil || strings.TrimSpace(secrets.BrowserStorageState) == "" { return nil, app.For(code.ThreadsAccount).ResNotFound("browser session not synced") } return &domusecase.BrowserSessionData{ AccountID: account.ID, StorageState: secrets.BrowserStorageState, UpdateAt: secrets.UpdateAt, }, nil } func (u *threadsAccountUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } if strings.TrimSpace(accountID) == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("account id is required") } return u.repo.FindByID(ctx, tenantID, ownerUID, accountID) } func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.Account) (*domusecase.AccountSummary, error) { browserConnected, apiConnected, err := u.connectionFlags(ctx, account.ID) if err != nil { return nil, err } return &domusecase.AccountSummary{ ID: account.ID, DisplayName: account.DisplayName, Username: account.Username, ThreadsUserID: account.ThreadsUserID, PersonaID: account.PersonaID, BrowserConnected: browserConnected, ApiConnected: apiConnected, Status: string(account.Status), CreateAt: account.CreateAt, UpdateAt: account.UpdateAt, }, nil } func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) { secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID) if err != nil { return false, false, err } if secrets == nil { return false, false, nil } browserConnected := strings.TrimSpace(secrets.BrowserStorageState) != "" apiConnected := strings.TrimSpace(secrets.APIAccessToken) != "" && (secrets.APITokenExpiresAt == 0 || secrets.APITokenExpiresAt > clock.NowUnixNano()) return browserConnected, apiConnected, nil } func (u *threadsAccountUseCase) loadConnectionPrefs(ctx context.Context, accountID string) (domusecase.ConnectionPrefs, error) { defaults := defaultConnectionPrefs() setting, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyConnectionPrefs) if err != nil { if isSettingNotFound(err) { return defaults, nil } return defaults, err } merged := mergeConnectionPrefs(defaults, setting.Value) return normalizeConnectionPrefs(merged), nil } func (u *threadsAccountUseCase) saveConnectionPrefs(ctx context.Context, accountID string, prefs domusecase.ConnectionPrefs) error { _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeAccount, ScopeID: accountID, Key: keyConnectionPrefs, Value: connectionPrefsToMap(prefs), }) return err } func requireActor(tenantID, ownerUID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.ThreadsAccount).InputMissingRequired("tenant_id and uid are required") } return nil } func accountLabel(account *entity.Account) string { if account == nil { return "未命名帳號" } if name := strings.TrimSpace(account.DisplayName); name != "" { return name } if name := strings.TrimSpace(account.Username); name != "" { return "@" + strings.TrimPrefix(name, "@") } return "未命名帳號" } func defaultConnectionPrefs() domusecase.ConnectionPrefs { return deriveConnectionPrefsFromDevMode(false) } // deriveConnectionPrefsFromDevMode maps the single dev_mode switch to concrete routing prefs. // dev_mode off → everything via Threads API; dev_mode on → everything via browser crawler. func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { if devMode { return domusecase.ConnectionPrefs{ DevMode: true, SearchViaApi: false, SearchSourceMode: "browser", PublishViaApi: false, ScrapeReplies: true, RepliesPerPost: defaultRepliesPerPost, PublishHeaded: false, PlaywrightDebug: false, } } return domusecase.ConnectionPrefs{ DevMode: false, SearchViaApi: true, SearchSourceMode: string(placement.DefaultSearchSourceMode), PublishViaApi: true, ScrapeReplies: false, RepliesPerPost: defaultRepliesPerPost, PublishHeaded: false, PlaywrightDebug: false, } } func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs { if patch.DevMode != nil { if *patch.DevMode { return deriveConnectionPrefsFromDevMode(true) } next := current next.DevMode = false if patch.SearchSourceMode != nil { next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) } return normalizeConnectionPrefs(next) } // Legacy callers may still send granular fields; normalize by current dev flag. devMode := current.DevMode if patch.SearchViaApi != nil && !*patch.SearchViaApi { devMode = true } if patch.PublishViaApi != nil && !*patch.PublishViaApi { devMode = true } if patch.ScrapeReplies != nil && *patch.ScrapeReplies { devMode = true } if devMode { return deriveConnectionPrefsFromDevMode(true) } next := current if patch.SearchSourceMode != nil { next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) } return normalizeConnectionPrefs(next) } func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs { if prefs.DevMode { return deriveConnectionPrefsFromDevMode(true) } prefs.SearchViaApi = true prefs.PublishViaApi = true prefs.ScrapeReplies = false prefs.PublishHeaded = false prefs.PlaywrightDebug = false mode := placement.WithoutCrawler(placement.ParseSearchSourceMode(prefs.SearchSourceMode)) prefs.SearchSourceMode = string(mode) return prefs } func mergeConnectionPrefs(defaults domusecase.ConnectionPrefs, value map[string]interface{}) domusecase.ConnectionPrefs { if value == nil { return defaults } if v, ok := value["search_via_api"].(bool); ok { defaults.SearchViaApi = v } if v, ok := value["search_source_mode"].(string); ok && strings.TrimSpace(v) != "" { defaults.SearchSourceMode = v } if v, ok := value["publish_via_api"].(bool); ok { defaults.PublishViaApi = v } if v, ok := value["dev_mode"].(bool); ok { defaults.DevMode = v } if v, ok := value["scrape_replies"].(bool); ok { defaults.ScrapeReplies = v } if v, ok := asInt(value["replies_per_post"]); ok { defaults.RepliesPerPost = v } if v, ok := value["publish_headed"].(bool); ok { defaults.PublishHeaded = v } if v, ok := value["playwright_debug"].(bool); ok { defaults.PlaywrightDebug = v } return defaults } func connectionPrefsToMap(prefs domusecase.ConnectionPrefs) map[string]interface{} { return map[string]interface{}{ "search_via_api": prefs.SearchViaApi, "search_source_mode": prefs.SearchSourceMode, "publish_via_api": prefs.PublishViaApi, "dev_mode": prefs.DevMode, "scrape_replies": prefs.ScrapeReplies, "replies_per_post": prefs.RepliesPerPost, "publish_headed": prefs.PublishHeaded, "playwright_debug": prefs.PlaywrightDebug, } } type playwrightStorageState struct { Cookies []interface{} `json:"cookies"` Origins []interface{} `json:"origins,omitempty"` } func normalizeStorageState(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { return "", app.For(code.ThreadsAccount).InputMissingRequired("storageState is required") } var parsed playwrightStorageState if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { return "", app.For(code.ThreadsAccount).InputInvalidFormat("storageState is not valid JSON") } if parsed.Cookies == nil { return "", app.For(code.ThreadsAccount).InputInvalidFormat("storageState must include cookies array") } raw, err := json.Marshal(parsed) if err != nil { return "", err } return string(raw), nil } func isSettingNotFound(err error) bool { var appErr *app.Error if errors.As(err, &appErr) { return appErr.Category() == code.ResNotFound } return false } func asInt(v interface{}) (int, bool) { switch n := v.(type) { case int: return n, true case int32: return int(n), true case int64: return int(n), true case float64: return int(n), true default: return 0, false } } func itoa(n int) string { if n <= 0 { return "1" } buf := make([]byte, 0, 12) for n > 0 { buf = append(buf, byte('0'+n%10)) n /= 10 } for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 { buf[i], buf[j] = buf[j], buf[i] } return string(buf) }