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

256 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}