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

256 lines
8.3 KiB
Go
Raw Normal View History

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
}