thread-master/backend/internal/model/threads_account/usecase/usecase.go

590 lines
18 KiB
Go
Raw Normal View History

2026-06-26 08:37:04 +00:00
package usecase
import (
"context"
"encoding/json"
"errors"
"strings"
"haixun-backend/internal/library/clock"
2026-06-26 16:02:06 +00:00
"haixun-backend/internal/library/crypto"
2026-06-26 08:37:04 +00:00
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/placement"
libthreads "haixun-backend/internal/library/threadsapi"
2026-06-26 08:37:04 +00:00
memberdomain "haixun-backend/internal/model/member/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
analyticsrepo "haixun-backend/internal/model/publish_analytics/domain/repository"
guardrepo "haixun-backend/internal/model/publish_guard/domain/repository"
inventoryrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
queuerepo "haixun-backend/internal/model/publish_queue/domain/repository"
eventrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
2026-06-26 08:37:04 +00:00
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
oauthState domrepo.OAuthStateStore
2026-06-26 08:37:04 +00:00
members memberdomain.UseCase
settings settingdomain.UseCase
personas personadomain.UseCase
queueRepo queuerepo.Repository
analytics analyticsrepo.Repository
inventory inventoryrepo.Repository
guard guardrepo.Repository
queueEvents eventrepo.Repository
2026-06-26 16:02:06 +00:00
cipher *crypto.Cipher
oauth libthreads.OAuthConfig
2026-06-26 08:37:04 +00:00
}
func NewUseCase(
repo domrepo.Repository,
secretsRepo domrepo.SecretsRepository,
oauthState domrepo.OAuthStateStore,
2026-06-26 08:37:04 +00:00
members memberdomain.UseCase,
settings settingdomain.UseCase,
personas personadomain.UseCase,
queueRepo queuerepo.Repository,
analytics analyticsrepo.Repository,
inventory inventoryrepo.Repository,
guard guardrepo.Repository,
queueEvents eventrepo.Repository,
2026-06-26 16:02:06 +00:00
cipher *crypto.Cipher,
oauth libthreads.OAuthConfig,
2026-06-26 08:37:04 +00:00
) domusecase.UseCase {
return &threadsAccountUseCase{
repo: repo,
secretsRepo: secretsRepo,
oauthState: oauthState,
2026-06-26 08:37:04 +00:00
members: members,
settings: settings,
personas: personas,
queueRepo: queueRepo,
analytics: analytics,
inventory: inventory,
guard: guard,
queueEvents: queueEvents,
2026-06-26 16:02:06 +00:00
cipher: cipher,
oauth: oauth,
2026-06-26 08:37:04 +00:00
}
}
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) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error {
if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil {
return err
}
mediaIDs := []string{}
if u.queueRepo != nil {
if ids, err := u.queueRepo.ListMediaIDsByAccount(ctx, tenantID, ownerUID, accountID); err == nil {
mediaIDs = ids
}
}
if u.analytics != nil {
_, _ = u.analytics.DeleteByMediaIDs(ctx, tenantID, ownerUID, mediaIDs)
}
if u.queueEvents != nil {
_ = u.queueEvents.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
}
if u.queueRepo != nil {
_, _ = u.queueRepo.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
}
if u.inventory != nil {
_ = u.inventory.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
}
if u.guard != nil {
_ = u.guard.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
}
if u.secretsRepo != nil {
_ = u.secretsRepo.DeleteByAccountID(ctx, accountID)
}
if u.settings != nil {
settings, _, _, _, err := u.settings.List(ctx, settingScopeAccount, accountID, 1, 200)
if err == nil {
for _, item := range settings {
if item == nil {
continue
}
_ = u.settings.Delete(ctx, settingScopeAccount, accountID, item.Key)
}
}
}
if err := u.repo.Delete(ctx, tenantID, ownerUID, accountID); err != nil {
return err
}
remaining, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
if err != nil {
return err
}
nextActiveID := ""
if len(remaining) > 0 && remaining[0] != nil {
nextActiveID = remaining[0].ID
}
return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, nextActiveID)
}
2026-06-26 08:37:04 +00:00
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
}
apiTokenExpiresAt := int64(0)
if secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID); err == nil && secrets != nil {
apiTokenExpiresAt = secrets.APITokenExpiresAt
}
2026-06-26 08:37:04 +00:00
return &domusecase.AccountSummary{
ID: account.ID,
DisplayName: account.DisplayName,
Username: account.Username,
ThreadsUserID: account.ThreadsUserID,
PersonaID: account.PersonaID,
BrowserConnected: browserConnected,
ApiConnected: apiConnected,
APITokenExpiresAt: apiTokenExpiresAt,
Status: string(account.Status),
CreateAt: account.CreateAt,
UpdateAt: account.UpdateAt,
2026-06-26 08:37:04 +00:00
}, nil
}
2026-06-27 16:03:28 +00:00
func (u *threadsAccountUseCase) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*domusecase.SecretsData, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
}
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return nil, app.For(code.ThreadsAccount).InputMissingRequired("access_token is required")
}
result, err := u.secretsRepo.SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt)
if err != nil {
return nil, err
}
return &domusecase.SecretsData{
AccountID: result.AccountID,
APIAccessToken: result.APIAccessToken,
APITokenExpiresAt: result.APITokenExpiresAt,
UpdateAt: result.UpdateAt,
}, nil
}
2026-06-26 08:37:04 +00:00
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 formalConnectionPrefs()
2026-06-26 08:37:04 +00:00
}
func formalConnectionPrefs() domusecase.ConnectionPrefs {
2026-06-26 08:37:04 +00:00
return domusecase.ConnectionPrefs{
DevMode: false,
SearchViaApi: true,
SearchSourceMode: string(placement.WithoutCrawler(placement.DefaultSearchSourceMode)),
2026-06-26 08:37:04 +00:00
PublishViaApi: true,
ScrapeReplies: false,
RepliesPerPost: defaultRepliesPerPost,
PublishHeaded: false,
PlaywrightDebug: false,
}
}
func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
next := normalizeConnectionPrefs(current)
if patch.ScrapeReplies != nil {
next.ScrapeReplies = *patch.ScrapeReplies
2026-06-26 08:37:04 +00:00
}
if patch.RepliesPerPost != nil && *patch.RepliesPerPost > 0 {
next.RepliesPerPost = *patch.RepliesPerPost
2026-06-26 08:37:04 +00:00
}
if patch.SearchSourceMode != nil {
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
}
return normalizeConnectionPrefs(next)
}
func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs {
out := formalConnectionPrefs()
out.ScrapeReplies = prefs.ScrapeReplies
if prefs.RepliesPerPost > 0 {
out.RepliesPerPost = prefs.RepliesPerPost
}
2026-06-27 14:49:17 +00:00
mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
out.SearchSourceMode = string(placement.WithoutCrawler(mode))
return out
2026-06-26 08:37:04 +00:00
}
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)
}