haixunMaster/haixun-backend/internal/model/threads_account/usecase/usecase.go

486 lines
14 KiB
Go
Raw Normal View History

2026-06-23 16:55:10 +00:00
package usecase
import (
"context"
"encoding/json"
"errors"
"strings"
"haixun-backend/internal/library/clock"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
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
}
func NewUseCase(
repo domrepo.Repository,
secretsRepo domrepo.SecretsRepository,
members memberdomain.UseCase,
settings settingdomain.UseCase,
personas personadomain.UseCase,
) domusecase.UseCase {
return &threadsAccountUseCase{
repo: repo,
secretsRepo: secretsRepo,
members: members,
settings: settings,
personas: personas,
}
}
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 deriveConnectionPrefsFromDevMode(merged.DevMode), 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: "api",
PublishViaApi: true,
ScrapeReplies: false,
RepliesPerPost: defaultRepliesPerPost,
PublishHeaded: false,
PlaywrightDebug: false,
}
}
func applyConnectionPatch(_ domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
if patch.DevMode != nil {
return deriveConnectionPrefsFromDevMode(*patch.DevMode)
}
// Legacy callers may still send granular fields; normalize by current dev flag.
devMode := false
if patch.SearchViaApi != nil && !*patch.SearchViaApi {
devMode = true
}
if patch.PublishViaApi != nil && !*patch.PublishViaApi {
devMode = true
}
if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
devMode = true
}
return deriveConnectionPrefsFromDevMode(devMode)
}
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)
}