256 lines
8.3 KiB
Go
256 lines
8.3 KiB
Go
|
|
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
|
|||
|
|
}
|