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
|
||
}
|