package usecase import ( "context" "strings" "haixun-backend/internal/library/clock" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libthreads "haixun-backend/internal/library/threadsapi" "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" ) func (u *threadsAccountUseCase) StartThreadsOAuth( ctx context.Context, tenantID, ownerUID, accountID string, ) (*domusecase.OAuthStartResult, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } if !u.oauth.Enabled() { return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定(THREADS_APP_ID / THREADS_APP_SECRET / THREADS_REDIRECT_URI)") } if u.oauthState == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定,無法保存 OAuth state") } accountID = strings.TrimSpace(accountID) var account *entity.Account var err error if accountID != "" { account, err = u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } } else { existing, listErr := u.repo.ListByOwner(ctx, tenantID, ownerUID) if listErr != nil { return nil, listErr } created, createErr := u.Create(ctx, domusecase.CreateRequest{ TenantID: tenantID, OwnerUID: ownerUID, DisplayName: "Threads 帳號 " + itoa(len(existing)+1), Activate: false, }) if createErr != nil { return nil, createErr } account, err = u.assertOwned(ctx, tenantID, ownerUID, created.ID) if err != nil { return nil, err } } state := uuid.NewString() if err := u.oauthState.Save(ctx, state, domrepo.OAuthStatePayload{ TenantID: tenantID, OwnerUID: ownerUID, AccountID: account.ID, }); err != nil { return nil, err } authorizeURL, err := libthreads.BuildAuthorizeURL(u.oauth, state) if err != nil { return nil, err } redirectURI := strings.TrimSpace(u.oauth.RedirectURI) if !strings.HasPrefix(strings.ToLower(redirectURI), "https://") { recordOAuthDebug("error", "start", account.ID, "redirect_uri must be https:// (Meta error 1349187): "+redirectURI) return nil, app.For(code.ThreadsAccount).InputInvalidFormat("THREADS_REDIRECT_URI 必須為 https://(Meta 會拒絕 http://,錯誤碼 1349187)") } recordOAuthDebug("info", "start", account.ID, "redirect_uri="+redirectURI) return &domusecase.OAuthStartResult{ AuthorizeURL: authorizeURL, State: state, AccountID: account.ID, }, nil } func (u *threadsAccountUseCase) CompleteThreadsOAuthCallback( ctx context.Context, authCode, state string, ) (*domusecase.OAuthCallbackResult, error) { if !u.oauth.Enabled() { return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定") } if u.oauthState == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定") } recordOAuthDebug("info", "callback", "", "received code len="+itoa(len(strings.TrimSpace(authCode)))+" state len="+itoa(len(strings.TrimSpace(state)))) payload, err := u.oauthState.Consume(ctx, state) if err != nil { recordOAuthDebug("error", "consume_state", "", err.Error()) return nil, err } short, err := libthreads.ExchangeCodeForToken(ctx, u.oauth, authCode) if err != nil { recordOAuthDebug("error", "exchange_code", payload.AccountID, err.Error()) return nil, err } recordOAuthDebug("info", "exchange_code", payload.AccountID, "short-lived ok expires_in="+itoa(short.ExpiresIn)) longLived, err := libthreads.ExchangeLongLivedToken(ctx, u.oauth, short.AccessToken) if err != nil { recordOAuthDebug("warn", "long_lived", payload.AccountID, "fallback short-lived: "+err.Error()) longLived = short } else { recordOAuthDebug("info", "long_lived", payload.AccountID, "ok expires_in="+itoa(longLived.ExpiresIn)) } account, err := u.assertOwned(ctx, payload.TenantID, payload.OwnerUID, payload.AccountID) if err != nil { return nil, err } profile, err := libthreads.GetMeProfile(ctx, longLived.AccessToken) threadsUserID := "" username := "" displayName := "" if err != nil { threadsUserID = libthreads.ThreadsUserIDFromToken(short, longLived) if threadsUserID == "" { recordOAuthDebug("error", "profile_me", payload.AccountID, err.Error()) if libthreads.IsPermissionError(err) { return nil, app.For(code.ThreadsAccount).AuthForbidden( "Threads API 需要 threads_basic:請到 Meta App Dashboard → 應用程式角色,把你的 Threads 帳號加為「測試人員」或管理員,並確認已啟用「Access the Threads API」", ) } return nil, err } recordOAuthDebug("warn", "profile_me", payload.AccountID, "fallback token user_id="+threadsUserID+": "+err.Error()) displayName = strings.TrimSpace(account.DisplayName) if displayName == "" { displayName = "Threads " + threadsUserID } } else { threadsUserID = profile.ID.String() username = profile.Username recordOAuthDebug("info", "profile_me", payload.AccountID, "username="+username+" threads_user_id="+threadsUserID) displayName = strings.TrimSpace(profile.Name) if displayName == "" { displayName = "@" + strings.TrimPrefix(username, "@") } } if err := u.assertNoAPIBindingConflict(ctx, payload.TenantID, payload.OwnerUID, account.ID, threadsUserID); err != nil { return nil, err } updated, err := u.repo.UpdateAPIProfile( ctx, payload.TenantID, payload.OwnerUID, account.ID, username, threadsUserID, displayName, ) if err != nil { return nil, err } expiresAt := int64(0) if longLived.ExpiresIn >= 300 { expiresAt = libthreads.NsFromNow(longLived.ExpiresIn) } else if longLived.ExpiresIn > 0 { recordOAuthDebug("warn", "expires_in", payload.AccountID, "suspicious expires_in="+itoa(longLived.ExpiresIn)+"s; store without expiry deadline") } if _, err := u.secretsRepo.SaveAPIAccessToken(ctx, updated.ID, longLived.AccessToken, expiresAt); err != nil { recordOAuthDebug("error", "save_token", updated.ID, err.Error()) return nil, err } recordOAuthDebug("info", "success", updated.ID, "api_connected=true username="+updated.Username) if err := u.members.SetActiveThreadsAccountID(ctx, payload.TenantID, payload.OwnerUID, updated.ID); err != nil { return nil, err } redirectURL := strings.TrimSpace(u.oauth.SuccessRedirect) if redirectURL == "" { redirectURL = "/" } sep := "?" if strings.Contains(redirectURL, "?") { sep = "&" } return &domusecase.OAuthCallbackResult{ AccountID: updated.ID, Username: updated.Username, ThreadsUserID: updated.ThreadsUserID, RedirectURL: redirectURL + sep + "threads_oauth=ok&account_id=" + updated.ID, }, nil } func (u *threadsAccountUseCase) DisconnectThreadsAPI( ctx context.Context, tenantID, ownerUID, accountID string, ) (*domusecase.AccountSummary, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } if err := u.secretsRepo.ClearAPIAccessToken(ctx, account.ID); err != nil { return nil, err } if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, account.ID); err != nil { return nil, err } return u.toSummary(ctx, account) } func (u *threadsAccountUseCase) assertNoAPIBindingConflict( ctx context.Context, tenantID, ownerUID, accountID, threadsUserID string, ) error { dup, err := u.repo.FindByThreadsUserID(ctx, tenantID, ownerUID, threadsUserID) if err != nil || dup == nil || dup.ID == accountID { return nil } secrets, err := u.secretsRepo.FindByAccountID(ctx, dup.ID) if err != nil { return err } if isValidAPIToken(secrets) { label := dup.DisplayName if label == "" && dup.Username != "" { label = "@" + dup.Username } if label == "" { label = dup.ID } return app.For(code.ThreadsAccount).ResConflict( "此 Threads 帳號已綁定在「" + label + "」;請先中斷該帳號 API,或選該帳號按「連線選中帳號」重新授權", ) } if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, dup.ID); err != nil { return err } return nil } func isValidAPIToken(secrets *entity.Secrets) bool { if secrets == nil { return false } token := strings.TrimSpace(secrets.APIAccessToken) if token == "" { return false } if secrets.APITokenExpiresAt > 0 && secrets.APITokenExpiresAt <= clock.NowUnixNano() { return false } return true }