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

328 lines
9.5 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
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
}
2026-07-13 08:59:13 +00:00
if s.Provider == nil || s.Provider.Name() == "unavailable" {
return "", "", fmt.Errorf("Threads OAuth is not configured")
}
2026-07-13 01:15:30 +00:00
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)
}