package usecase import ( "context" "crypto/rand" "encoding/hex" "fmt" "time" "apps/backend/internal/module/threads/domain" "github.com/google/uuid" ) // TokenRenewScheduler schedules delayed threads_token_renew jobs (optional). type TokenRenewScheduler interface { ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error } // Service implements Threads OAuth + account lifecycle. type Service struct { Repo domain.Repository Provider domain.Provider TokenSecret string // APIPublicBase for building fake authorize → callback (e.g. http://127.0.0.1:8888) APIPublicBase string // CallbackPath relative or absolute redirect_uri registered with Meta CallbackPath string // default /api/v1/threads-accounts/oauth/callback // WebBase for post-callback browser redirect WebBase string // Renew schedules worker job ~30d after bind (optional) Renew TokenRenewScheduler } func New(repo domain.Repository, provider domain.Provider, tokenSecret string) *Service { return &Service{ Repo: repo, Provider: provider, TokenSecret: tokenSecret, CallbackPath: "/api/v1/threads-accounts/oauth/callback", } } func (s *Service) callbackURI() string { if s.CallbackPath == "" { s.CallbackPath = "/api/v1/threads-accounts/oauth/callback" } if len(s.CallbackPath) > 0 && s.CallbackPath[0] == '/' { base := trimSlash(s.APIPublicBase) if base == "" { return s.CallbackPath } return base + s.CallbackPath } return s.CallbackPath } func trimSlash(s string) string { for len(s) > 0 && s[len(s)-1] == '/' { s = s[:len(s)-1] } return s } func randomState() string { var b [16]byte _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) } // OAuthStart — TH-01 func (s *Service) OAuthStart(ctx context.Context, ownerUID int64) (authorizeURL, state string, err error) { if ownerUID <= 0 { return "", "", domain.ErrForbidden } state = randomState() now := domain.NowNano() st := &domain.OAuthState{ State: state, OwnerUID: ownerUID, CreatedAt: now, ExpiresAt: now + int64(15*time.Minute), } if err := s.Repo.SaveState(ctx, st); err != nil { return "", "", err } uri := s.callbackURI() return s.Provider.AuthorizeURL(state, uri), state, nil } // OAuthCallback — TH-02 / TH-03 / TH-04 func (s *Service) OAuthCallback(ctx context.Context, code, state, oauthErr string) (*domain.Account, error) { if oauthErr != "" { return nil, domain.ErrOAuthDenied } if state == "" { return nil, domain.ErrInvalidState } st, err := s.Repo.TakeState(ctx, state) if err != nil { return nil, err } if code == "" { return nil, domain.ErrOAuthExchange } tok, err := s.Provider.ExchangeCode(ctx, code, s.callbackURI()) if err != nil { return nil, domain.ErrOAuthExchange } accEnc, err := domain.Seal(s.TokenSecret, tok.AccessToken) if err != nil { return nil, err } refEnc, err := domain.Seal(s.TokenSecret, tok.RefreshToken) if err != nil { return nil, err } if tok.RefreshToken == "" { // long-lived access doubles as refresh handle refEnc, _ = domain.Seal(s.TokenSecret, tok.AccessToken) } now := domain.NowNano() exp := expiresAtFromSeconds(now, tok.ExpiresIn) username := tok.Username if username == "" { username = "user_" + tok.UserID } display := tok.DisplayName if display == "" { display = username } // Same Threads user under same member → overwrite session (do not create duplicates). if tok.UserID != "" { if existing, ferr := s.Repo.FindByOwnerAndThreadsUser(ctx, st.OwnerUID, tok.UserID); ferr == nil && existing != nil { existing.Username = username existing.DisplayName = display existing.ThreadsUserID = tok.UserID existing.Connection = domain.ConnectionConnected existing.IsUsable = true existing.ErrorMessage = "" existing.AvatarColor = domain.AvatarColorFor(username) if tok.AvatarURL != "" { existing.AvatarURL = tok.AvatarURL } existing.SessionExpiresAt = exp existing.SessionRefreshedAt = now existing.AccessTokenEnc = accEnc existing.RefreshTokenEnc = refEnc existing.UpdatedAt = now if err := s.Repo.Update(ctx, existing); err != nil { return nil, err } s.scheduleRenew(ctx, existing) return existing, nil } } a := &domain.Account{ ID: uuid.NewString(), OwnerUID: st.OwnerUID, Username: username, DisplayName: display, ThreadsUserID: tok.UserID, Connection: domain.ConnectionConnected, IsUsable: true, AvatarColor: domain.AvatarColorFor(username), AvatarURL: tok.AvatarURL, SessionExpiresAt: exp, SessionRefreshedAt: now, AccessTokenEnc: accEnc, RefreshTokenEnc: refEnc, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, a); err != nil { return nil, err } s.scheduleRenew(ctx, a) return a, nil } func (s *Service) scheduleRenew(ctx context.Context, a *domain.Account) { if s == nil || s.Renew == nil || a == nil { return } // 約 30 天後 renew(長效 token ~60 天;若到期更近則提前) runAfter := domain.NowNano() + int64(30*24*time.Hour) if a.SessionExpiresAt > 0 { // 到期前 30 天;若不足 7 天則到期前 1 天 early := a.SessionExpiresAt - int64(30*24*time.Hour) minLead := a.SessionExpiresAt - int64(7*24*time.Hour) if early > domain.NowNano() { runAfter = early } else if minLead > domain.NowNano() { runAfter = minLead } else { runAfter = domain.NowNano() + int64(time.Hour) // 幾乎到期,盡快 } } _ = s.Renew.ScheduleTokenRenew(ctx, a.OwnerUID, a.ID, runAfter) } // List — TH-05 func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Account, error) { return s.Repo.ListByOwner(ctx, ownerUID) } // Get returns account by id (no ownership check — caller enforces). func (s *Service) Get(ctx context.Context, accountID string) (*domain.Account, error) { return s.Repo.FindByID(ctx, accountID) } // AccessToken decrypts stored access token for publish transport. func (s *Service) AccessToken(_ context.Context, a *domain.Account) (string, error) { if a == nil { return "", domain.ErrNotFound } return domain.Open(s.TokenSecret, a.AccessTokenEnc) } // Refresh — TH-06 / TH-07 / TH-10 // 用庫裡已存的長效 token 向 Threads 換新 token(不開授權頁、不重新 OAuth)。 // 若 token 已失效無法 refresh,標記 error,需使用者再按「連帳」走授權。 func (s *Service) Refresh(ctx context.Context, ownerUID int64, accountID string) (*domain.Account, error) { a, err := s.Repo.FindByID(ctx, accountID) if err != nil { return nil, err } if a.OwnerUID != ownerUID { return nil, domain.ErrForbidden } // 優先用 refresh 把手;沒有則退回 access(兩者在長效模型下通常相同) ref, err := domain.Open(s.TokenSecret, a.RefreshTokenEnc) if err != nil || ref == "" { ref, err = domain.Open(s.TokenSecret, a.AccessTokenEnc) } if err != nil || ref == "" { a.Connection = domain.ConnectionError a.IsUsable = false a.ErrorMessage = "no stored token — reconnect account" _ = s.Repo.Update(ctx, a) return a, domain.ErrRefreshFailed } tok, err := s.Provider.Refresh(ctx, ref) if err != nil { a.Connection = domain.ConnectionError a.IsUsable = false a.ErrorMessage = "token refresh failed — reconnect account" _ = s.Repo.Update(ctx, a) return a, domain.ErrRefreshFailed } a.AccessTokenEnc, _ = domain.Seal(s.TokenSecret, tok.AccessToken) // 長效 refresh:新 token 同時當下次 refresh 把手 handle := tok.RefreshToken if handle == "" { handle = tok.AccessToken } a.RefreshTokenEnc, _ = domain.Seal(s.TokenSecret, handle) now := domain.NowNano() a.SessionRefreshedAt = now a.SessionExpiresAt = expiresAtFromSeconds(now, tok.ExpiresIn) a.Connection = domain.ConnectionConnected a.IsUsable = true a.ErrorMessage = "" if tok.Username != "" { a.Username = tok.Username } if tok.DisplayName != "" { a.DisplayName = tok.DisplayName } a.UpdatedAt = now if err := s.Repo.Update(ctx, a); err != nil { return nil, err } return a, nil } // Disconnect — TH-08 / TH-10:真刪綁定(列表不再出現) func (s *Service) Disconnect(ctx context.Context, ownerUID int64, accountID string) error { a, err := s.Repo.FindByID(ctx, accountID) if err != nil { return err } if a.OwnerUID != ownerUID { return domain.ErrForbidden } return s.Repo.Delete(ctx, accountID) } // WebRedirect builds FE redirect after callback. func (s *Service) WebRedirect(ok bool, msg string) string { base := trimSlash(s.WebBase) if base == "" { base = "http://127.0.0.1:5173" } if ok { return base + "/app/crew?oauth=ok" } if msg == "" { msg = "error" } return fmt.Sprintf("%s/app/crew?oauth=error&msg=%s", base, urlQueryEscape(msg)) } // expiresAtFromSeconds: nowUnixNano + expiresInSec → absolute unix nanoseconds. // expiresInSec<=0 → 預設 60 天(Threads 長效常見)。 func expiresAtFromSeconds(nowNano, expiresInSec int64) int64 { const nsPerSec = int64(1_000_000_000) if expiresInSec <= 0 { expiresInSec = 60 * 24 * 3600 } // 防呆:若誤傳毫秒級(> 10 年以秒計)當毫秒 if expiresInSec > 10*365*24*3600 { expiresInSec = expiresInSec / 1000 } return nowNano + expiresInSec*nsPerSec } func urlQueryEscape(s string) string { // minimal escape r := make([]byte, 0, len(s)*2) for i := 0; i < len(s); i++ { c := s[i] if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { r = append(r, c) } else if c == ' ' { r = append(r, '+') } else { r = append(r, '%') const hex = "0123456789ABCDEF" r = append(r, hex[c>>4], hex[c&15]) } } return string(r) }