thread-master/apps/backend/internal/module/threads/usecase/service.go

325 lines
9.4 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"
"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)
}