520 lines
16 KiB
Go
520 lines
16 KiB
Go
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)
|
|
}
|